Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fbe9c9b56 | ||
|
|
fd3a217cbe | ||
|
|
ff6efec4c5 | ||
|
|
738d2e2256 | ||
|
|
2e3e1493f5 | ||
|
|
fd2fd8298d | ||
|
|
df7cd99060 | ||
|
|
c273cea067 | ||
|
|
1f969e6cac | ||
|
|
c1132cbe19 | ||
|
|
b28ad88b22 | ||
|
|
b871d62de6 |
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** 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`).
|
- **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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** 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`).
|
- **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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"pid": 51184,
|
|
||||||
"version": "0.9.9",
|
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
|
||||||
"startedAt": 1784215907952
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"reui": {
|
"reui": {
|
||||||
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
|
"url": "https://mcp.reui.io",
|
||||||
"headers": {
|
"headers": {
|
||||||
"X-Reui-Style": "base-nova"
|
"Authorization": "Bearer <REUI_LICENSE_KEY>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# KPI hybrid — только kit (stats-12 DNA)
|
||||||
|
|
||||||
|
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||||
|
|
||||||
|
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
|
||||||
|
|
||||||
|
## MUST
|
||||||
|
|
||||||
|
| Зона | Компонент / DNA |
|
||||||
|
|------|-----------------|
|
||||||
|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||||
|
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||||
|
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
|
||||||
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||||
|
|
||||||
|
Импорты UI: `@evobgp/ui/components/*`.
|
||||||
|
|
||||||
|
## NEVER
|
||||||
|
|
||||||
|
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||||
|
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||||
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
|
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# ReUI MCP — обязательно (PRO + free)
|
||||||
|
|
||||||
|
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
|
||||||
|
|
||||||
|
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||||
|
|
||||||
|
| Документ | URL |
|
||||||
|
|----------|-----|
|
||||||
|
| **llms.txt** | https://reui.io/llms.txt |
|
||||||
|
| **Get Started** | https://reui.io/docs/get-started |
|
||||||
|
| **Styling** | https://reui.io/docs/styling |
|
||||||
|
| **MCP** | https://reui.io/docs/mcp |
|
||||||
|
| **Blocks** | https://reui.io/blocks |
|
||||||
|
| **Settings blocks** | https://reui.io/blocks/application/settings |
|
||||||
|
| **License** | https://reui.io/docs/license-setup |
|
||||||
|
| **Base UI components** | https://reui.io/docs/components/base/<name> |
|
||||||
|
|
||||||
|
## Primary MCP
|
||||||
|
|
||||||
|
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
|
||||||
|
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
|
||||||
|
|
||||||
|
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||||
|
|
||||||
|
## Когда ReUI vs shadcn
|
||||||
|
|
||||||
|
| Задача | Registry | Импорт |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
|
||||||
|
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
|
||||||
|
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
||||||
|
| Filters | `@reui` | `@/components/reui/filters` |
|
||||||
|
| Frame surface | `@reui` | `@/components/reui/frame` |
|
||||||
|
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
|
||||||
|
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
||||||
|
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
||||||
|
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
|
||||||
|
|
||||||
|
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
|
||||||
|
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
|
||||||
|
|
||||||
|
## MCP workflow
|
||||||
|
|
||||||
|
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
|
||||||
|
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
|
||||||
|
2. Страница целиком → `compose_page`; секция → `get_block`
|
||||||
|
3. `get_component` для API primitives из `componentsUsed`
|
||||||
|
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
|
||||||
|
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
|
||||||
|
6. Adapt by reuse → kit / route
|
||||||
|
7. `validate_usage` + `get_audit_checklist`
|
||||||
|
|
||||||
|
## Размещение
|
||||||
|
|
||||||
|
| Слой | Путь | Импорт |
|
||||||
|
|------|------|--------|
|
||||||
|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
|
||||||
|
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
|
||||||
|
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
|
||||||
|
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
|
||||||
|
|
||||||
|
## Установленные ReUI (apps/web)
|
||||||
|
|
||||||
|
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
||||||
|
|
||||||
|
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
||||||
|
|
||||||
|
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env.local (gitignored)
|
||||||
|
REUI_LICENSE_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
|
||||||
|
|
||||||
|
## Эталоны preview
|
||||||
|
|
||||||
|
| Зона | Preview |
|
||||||
|
|------|---------|
|
||||||
|
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
|
||||||
|
| List | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
|
| Settings | https://reui.io/preview/base/settings-16 |
|
||||||
|
| Auth | https://reui.io/preview/base/auth-13 |
|
||||||
|
| Shell | https://reui.io/preview/base/app-shell-12 |
|
||||||
|
| Empty | https://reui.io/preview/base/empty-state-12 |
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
- Копипаст с reui.io без CLI
|
||||||
|
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
|
||||||
|
- Radix-варианты docs — только Base UI
|
||||||
|
- Raw `bg-emerald-*` вместо ReUI `variant`
|
||||||
|
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||||
|
- Смешивать Card и Frame на ops-экране
|
||||||
|
- Ставить shadcn/ui blocks выше ReUI PRO
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] `user-reui` search/get_block + previewUrl
|
||||||
|
- [ ] `surface: frame`
|
||||||
|
- [ ] CLI add из `apps/web` при новом item
|
||||||
|
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
|
||||||
|
- [ ] `pnpm --filter @evobgp/web run build`
|
||||||
@@ -11,7 +11,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ alwaysApply: false
|
|||||||
# Web UI — React + shadcn/ui + ReUI
|
# 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
|
- 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
|
## Слои UI
|
||||||
|
|
||||||
@@ -23,18 +23,18 @@ alwaysApply: false
|
|||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
| 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) |
|
| Роуты | `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>`.
|
Тема: `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.
|
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
|
||||||
*Rationale:* единый источник правды и API.
|
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
|
||||||
*Проверка:* review; нет самописных примитивов, если есть registry item.
|
*Проверка:* 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>`.
|
**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).
|
*Проверка:* файлы в `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-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`.
|
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** 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`).
|
- **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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
|
||||||
deploy/compose/runtime-logs/
|
deploy/compose/runtime-logs/
|
||||||
|
# Local MCP configs (may contain REUI license Bearer)
|
||||||
|
.cursor/mcp.json
|
||||||
|
.mcp.json
|
||||||
|
.codegraph/daemon.pid
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"codegraph": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "codegraph",
|
|
||||||
"args": [
|
|
||||||
"serve",
|
|
||||||
"--mcp"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reui": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://mcp.reui.io/api/mcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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:** 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`).
|
- **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
|
## kanban
|
||||||
|
|
||||||
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type { FirewallClient } from '@/types/api'
|
|
||||||
|
|
||||||
function formatPacketCount(value?: number | null): string | null {
|
|
||||||
if (value == null || value <= 0) return null
|
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
|
||||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FirewallClientsGridProps {
|
|
||||||
clients: FirewallClient[]
|
|
||||||
isLoading?: boolean
|
|
||||||
onApprove: (id: string) => void
|
|
||||||
onReject: (id: string) => void
|
|
||||||
approvePending?: boolean
|
|
||||||
rejectPending?: boolean
|
|
||||||
emptyTitle?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FirewallClientsGrid({
|
|
||||||
clients,
|
|
||||||
isLoading = false,
|
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
approvePending = false,
|
|
||||||
rejectPending = false,
|
|
||||||
emptyTitle = 'Нет клиентов',
|
|
||||||
}: FirewallClientsGridProps) {
|
|
||||||
const columns = useMemo<ColumnDef<FirewallClient>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
title={row.original.name}
|
|
||||||
subtitle={row.original.hostname || row.original.token_prefix}
|
|
||||||
accent="primary"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Имя' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
meta: { headerTitle: 'Статус' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'last_seen_at',
|
|
||||||
accessorFn: (row) => row.last_seen_at ?? '',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
|
|
||||||
),
|
|
||||||
sortingFn: (a, b) => {
|
|
||||||
const av = a.original.last_seen_at ?? ''
|
|
||||||
const bv = b.original.last_seen_at ?? ''
|
|
||||||
return av.localeCompare(bv)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Последняя активность' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apply',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Применение',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original
|
|
||||||
return (
|
|
||||||
<span className="text-xs">
|
|
||||||
{c.last_apply_status ?? '—'}
|
|
||||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Применение' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'packets',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Пакеты',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
|
|
||||||
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
|
|
||||||
if (!dropped && !accepted) {
|
|
||||||
return <span className="text-muted-foreground text-xs">—</span>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{dropped ? <span className="text-destructive">↓{dropped}</span> : null}
|
|
||||||
{dropped && accepted ? ' · ' : null}
|
|
||||||
{accepted ? <span className="text-success">↑{accepted}</span> : null}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Пакеты' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => null,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{c.status === 'pending' ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
disabled={approvePending}
|
|
||||||
onClick={() => onApprove(c.id)}
|
|
||||||
>
|
|
||||||
Одобрить
|
|
||||||
</Button>
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={rejectPending}
|
|
||||||
>
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Отклонить запрос?"
|
|
||||||
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
|
|
||||||
confirmLabel="Отклонить"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => onReject(c.id)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{c.status === 'approved' ? (
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
type="button"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={rejectPending}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Удалить клиент?"
|
|
||||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => onReject(c.id)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[approvePending, onApprove, onReject, rejectPending],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: clients,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridSection
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage={emptyTitle}
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск клиентов…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
|
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { CommunitySelect } from '@/components/modules/community-select'
|
|
||||||
import { SelectField } from '@/components/select-field'
|
|
||||||
import { useCreateFirewallRule } from '@/queries/firewall'
|
|
||||||
import type { BgpCommunity } from '@/types/api'
|
|
||||||
|
|
||||||
const FIREWALL_ACTION_ITEMS = [
|
|
||||||
{ value: 'block', label: 'block' },
|
|
||||||
{ value: 'accept', label: 'accept' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
interface FirewallRuleCreateDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
communities: BgpCommunity[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FirewallRuleCreateDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
communities,
|
|
||||||
}: FirewallRuleCreateDialogProps) {
|
|
||||||
const createMutation = useCreateFirewallRule()
|
|
||||||
const [action, setAction] = useState<'block' | 'accept'>('block')
|
|
||||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
|
||||||
const [comment, setComment] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
setAction('block')
|
|
||||||
setCommunityId(null)
|
|
||||||
setComment('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
try {
|
|
||||||
await createMutation.mutateAsync({
|
|
||||||
scope: 'tenant',
|
|
||||||
action,
|
|
||||||
community_id: communityId,
|
|
||||||
comment: comment.trim(),
|
|
||||||
})
|
|
||||||
onOpenChange(false)
|
|
||||||
} catch {
|
|
||||||
// toast handled in mutation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormDrawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title="Новое правило"
|
|
||||||
className="sm:max-w-md"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
|
|
||||||
Добавить
|
|
||||||
</LoadingButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectField
|
|
||||||
id="fw-rule-action"
|
|
||||||
label="Действие"
|
|
||||||
items={[...FIREWALL_ACTION_ITEMS]}
|
|
||||||
value={action}
|
|
||||||
placeholder="Выберите действие"
|
|
||||||
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
|
|
||||||
/>
|
|
||||||
<CommunitySelect
|
|
||||||
id="fw-rule-community"
|
|
||||||
label="Community"
|
|
||||||
value={communityId}
|
|
||||||
onValueChange={setCommunityId}
|
|
||||||
communities={communities}
|
|
||||||
nullable
|
|
||||||
placeholder="Все communities"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
|
||||||
<Input
|
|
||||||
id="fw-rule-comment"
|
|
||||||
placeholder="Комментарий"
|
|
||||||
value={comment}
|
|
||||||
onChange={(e) => setComment(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormDrawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
|
||||||
import type { BgpCommunity, FirewallRule } from '@/types/api'
|
|
||||||
|
|
||||||
export interface FirewallRulesGridProps {
|
|
||||||
rules: FirewallRule[]
|
|
||||||
communities: BgpCommunity[]
|
|
||||||
isLoading?: boolean
|
|
||||||
onDelete: (id: string) => void
|
|
||||||
deletePending?: boolean
|
|
||||||
emptyTitle?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FirewallRulesGrid({
|
|
||||||
rules,
|
|
||||||
communities,
|
|
||||||
isLoading = false,
|
|
||||||
onDelete,
|
|
||||||
deletePending = false,
|
|
||||||
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
|
|
||||||
}: FirewallRulesGridProps) {
|
|
||||||
const columns = useMemo<ColumnDef<FirewallRule>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'priority',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
|
|
||||||
cell: ({ row }) => row.original.priority,
|
|
||||||
meta: { headerTitle: '#' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'action',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StatusBadge status={row.original.action} label={row.original.action} />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Действие' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'community',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Community',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm">
|
|
||||||
{row.original.community_id
|
|
||||||
? communityLabel(row.original.community_id, communities)
|
|
||||||
: 'Все'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Community' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'comment',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Комментарий',
|
|
||||||
cell: ({ row }) => row.original.comment || '—',
|
|
||||||
meta: { headerTitle: 'Комментарий' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => null,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original
|
|
||||||
return (
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
type="button"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={deletePending}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Удалить правило?"
|
|
||||||
description={
|
|
||||||
r.comment
|
|
||||||
? `Правило #${r.priority} (${r.action}): ${r.comment}`
|
|
||||||
: `Правило #${r.priority} (${r.action}) будет удалено.`
|
|
||||||
}
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => onDelete(r.id)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[communities, deletePending, onDelete],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: rules,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridSection
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage={emptyTitle}
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск правил…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
Shield,
|
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ import { AppsMenu } from '@/components/layout/apps-menu'
|
|||||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||||
import { NavUser } from '@/components/layout/nav-user'
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
|
||||||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -90,7 +88,6 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||||
{ to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' },
|
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||||
],
|
],
|
||||||
@@ -221,7 +218,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<AppsMenu />
|
<AppsMenu />
|
||||||
<SystemMonitorPopover />
|
<SystemMonitorPopover />
|
||||||
<ModeToggle />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
import {
|
import {
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
MoonIcon,
|
||||||
|
PaletteIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
SunIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -33,12 +41,71 @@ import {
|
|||||||
resetPortalHandoff,
|
resetPortalHandoff,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
|
||||||
/**
|
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||||
* Sidebar footer account menu.
|
|
||||||
* Portal mode → shows JWT email + logout via auth-portal.
|
const THEMES = [
|
||||||
* Local mode → shows the API-key hint + clears the local token.
|
{
|
||||||
* @see https://reui.io/preview/base/app-shell-12
|
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 {
|
function initials(source: string): string {
|
||||||
const base = source.trim()
|
const base = source.trim()
|
||||||
if (!base) return '?'
|
if (!base) return '?'
|
||||||
@@ -56,8 +123,7 @@ export function NavUser() {
|
|||||||
|
|
||||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||||
const email =
|
const email =
|
||||||
claims?.email?.trim() ||
|
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
|
||||||
(authOn ? '' : 'локальный API-токен')
|
|
||||||
const fallback = initials(name || email)
|
const fallback = initials(name || email)
|
||||||
|
|
||||||
function handleSignOut() {
|
function handleSignOut() {
|
||||||
@@ -87,9 +153,7 @@ export function NavUser() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar className="size-8 rounded-lg">
|
<Avatar className="size-8 rounded-lg">
|
||||||
<AvatarFallback className="rounded-lg text-xs">
|
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||||
{fallback}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{name}</span>
|
<span className="truncate font-semibold">{name}</span>
|
||||||
@@ -140,6 +204,13 @@ export function NavUser() {
|
|||||||
Открыть Auth Portal
|
Открыть Auth Portal
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||||
|
<PaletteIcon aria-hidden />
|
||||||
|
Тема
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeSegmentedToggle />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -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,274 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
|
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
|
import { FormDrawer } from '@/components/form-drawer'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import {
|
||||||
|
createFilter,
|
||||||
|
type Filter,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
} from '@/components/reui/filters'
|
||||||
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
|
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||||
|
import {
|
||||||
|
useApproveDiscoveredPeerMutation,
|
||||||
|
useRejectDiscoveredPeerMutation,
|
||||||
|
} from '@/queries/network'
|
||||||
|
import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending BGP peer discoveries — approve / reject.
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* @see https://reui.io/preview/base/components/c-empty-1
|
||||||
|
*/
|
||||||
|
|
||||||
|
function speakerLabel(s: SpeakerRow): string {
|
||||||
|
if (s.role === 'master') {
|
||||||
|
const host = s.agent_domain ?? s.endpoint
|
||||||
|
return host ? `CP · ${host}` : 'CP (master)'
|
||||||
|
}
|
||||||
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultFilters(): Filter[] {
|
||||||
|
return [createFilter('neighbor', 'contains', [''])]
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterFields: FilterFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'neighbor',
|
||||||
|
label: 'Сосед',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-48',
|
||||||
|
placeholder: 'IP или Neighbor ID…',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface NetworkDiscoveredPeersCardProps {
|
||||||
|
items: PeerDiscoveryRow[]
|
||||||
|
speakers: SpeakerRow[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkDiscoveredPeersCard({
|
||||||
|
items,
|
||||||
|
speakers,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: NetworkDiscoveredPeersCardProps) {
|
||||||
|
const approveMutation = useApproveDiscoveredPeerMutation()
|
||||||
|
const rejectMutation = useRejectDiscoveredPeerMutation()
|
||||||
|
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
||||||
|
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||||
|
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [speakerId, setSpeakerId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const speakerItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: '', label: 'Авто (с ноды обнаружения)' },
|
||||||
|
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
|
||||||
|
],
|
||||||
|
[speakers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'neighbor_id',
|
||||||
|
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Neighbor ID" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.neighbor_id || '—'}
|
||||||
|
subtitle={row.original.neighbor}
|
||||||
|
accent="mono"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Neighbor ID' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 }) => (
|
||||||
|
<StatusBadge
|
||||||
|
status={row.original.session_state ?? '—'}
|
||||||
|
label={bgpSessionStateRu(row.original.session_state)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Состояние' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setApproveTarget(row.original)
|
||||||
|
setName('')
|
||||||
|
setSpeakerId(row.original.speaker_id ?? null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Одобрить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setRejectTarget(row.original)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
Отклонить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
function getFilterFieldValue(item: PeerDiscoveryRow, field: string): unknown {
|
||||||
|
if (field === 'neighbor') {
|
||||||
|
return `${item.neighbor_id ?? ''} ${item.neighbor}`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmApprove() {
|
||||||
|
if (!approveTarget) return
|
||||||
|
await approveMutation.mutateAsync({
|
||||||
|
id: approveTarget.id,
|
||||||
|
body: {
|
||||||
|
name: name.trim() || undefined,
|
||||||
|
bgp_speaker_id: speakerId || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setApproveTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ResourcePage
|
||||||
|
title="На одобрение"
|
||||||
|
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
||||||
|
filterFields={filterFields}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
onClearFilters={() => setFilters(createDefaultFilters())}
|
||||||
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error instanceof Error ? error : null}
|
||||||
|
onRetry={onRetry}
|
||||||
|
emptyState={{
|
||||||
|
title: 'Нет ожидающих пиров',
|
||||||
|
description:
|
||||||
|
'Включите peer discovery в параметрах BIRD и задайте CIDR-диапазоны. Новые сессии появятся здесь.',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormDrawer
|
||||||
|
open={!!approveTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setApproveTarget(null)
|
||||||
|
}}
|
||||||
|
title="Одобрить пира"
|
||||||
|
description={
|
||||||
|
approveTarget
|
||||||
|
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="sm:max-w-sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => setApproveTarget(null)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
type="button"
|
||||||
|
loading={approveMutation.isPending}
|
||||||
|
onClick={() => void confirmApprove()}
|
||||||
|
>
|
||||||
|
Одобрить
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="disc-name">Имя (опционально)</Label>
|
||||||
|
<Input
|
||||||
|
id="disc-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="client-edge-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SelectField
|
||||||
|
id="disc-speaker"
|
||||||
|
label="Спикер"
|
||||||
|
items={speakerItems}
|
||||||
|
value={speakerId ?? ''}
|
||||||
|
onValueChange={(v) => setSpeakerId(v || null)}
|
||||||
|
placeholder="Авто"
|
||||||
|
/>
|
||||||
|
</FormDrawer>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!rejectTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRejectTarget(null)
|
||||||
|
}}
|
||||||
|
title="Отклонить пира?"
|
||||||
|
description={
|
||||||
|
rejectTarget
|
||||||
|
? `${rejectTarget.neighbor_id || rejectTarget.neighbor} больше не будет появляться в списке.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Отклонить"
|
||||||
|
destructive
|
||||||
|
confirmLoading={rejectMutation.isPending}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!rejectTarget) return
|
||||||
|
void rejectMutation.mutateAsync(rejectTarget.id).then(() => setRejectTarget(null))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
|
||||||
|
export function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return 'grid-cols-1'
|
||||||
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
|
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||||
|
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
|
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||||
|
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||||
|
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router'
|
|||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { kpiCols } from './kpi-cols'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
@@ -47,16 +48,6 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
|||||||
destructive: 'text-destructive',
|
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>) {
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
export interface QuickActionItem {
|
export interface QuickActionItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -30,16 +31,6 @@ interface QuickActionGridProps {
|
|||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
function kpiCols(count: number): string {
|
|
||||||
if (count <= 1) return 'grid-cols-1'
|
|
||||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
|
||||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
|
||||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
|
||||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
|
||||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
|
||||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
|||||||
@@ -255,9 +255,34 @@ export function can(required: string): boolean {
|
|||||||
const claims = getClaims()
|
const claims = getClaims()
|
||||||
if (!claims) return false
|
if (!claims) return false
|
||||||
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
||||||
|
if (claims.is_admin) return true
|
||||||
return hasPermission(claims.permissions, required)
|
return hasPermission(claims.permissions, required)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether /v1/auth/session may manage API keys (`bgp:access:admin`).
|
||||||
|
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key operator.
|
||||||
|
*/
|
||||||
|
export function sessionCanManageApiKeys(session: {
|
||||||
|
role?: string
|
||||||
|
kind?: string
|
||||||
|
is_admin?: boolean
|
||||||
|
permissions?: readonly string[]
|
||||||
|
} | null | undefined): boolean {
|
||||||
|
if (!session) return false
|
||||||
|
const jwtPath =
|
||||||
|
session.kind === 'jwt' ||
|
||||||
|
session.is_admin === true ||
|
||||||
|
(session.permissions?.length ?? 0) > 0
|
||||||
|
if (jwtPath) {
|
||||||
|
return (
|
||||||
|
session.is_admin === true ||
|
||||||
|
hasPermission(session.permissions ?? [], 'bgp:access:admin')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return session.role === 'operator'
|
||||||
|
}
|
||||||
|
|
||||||
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||||
export function permissionForPath(pathname: string): string | null {
|
export function permissionForPath(pathname: string): string | null {
|
||||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||||
@@ -268,7 +293,6 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
||||||
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
||||||
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
||||||
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
|
|
||||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
@@ -288,7 +312,6 @@ export function firstAllowedPath(): string {
|
|||||||
'/network',
|
'/network',
|
||||||
'/directories',
|
'/directories',
|
||||||
'/operations',
|
'/operations',
|
||||||
'/firewall',
|
|
||||||
'/schedule',
|
'/schedule',
|
||||||
'/monitoring',
|
'/monitoring',
|
||||||
'/access',
|
'/access',
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,9 @@ import type {
|
|||||||
BgpSpeakerCreate,
|
BgpSpeakerCreate,
|
||||||
BgpSpeakerPatch,
|
BgpSpeakerPatch,
|
||||||
BirdStatus,
|
BirdStatus,
|
||||||
|
PeerDiscoveryApprove,
|
||||||
|
PeerDiscoveriesResponse,
|
||||||
|
PeerDiscoveryRow,
|
||||||
PeerRow,
|
PeerRow,
|
||||||
PeersResponse,
|
PeersResponse,
|
||||||
SpeakerRow,
|
SpeakerRow,
|
||||||
@@ -19,6 +22,7 @@ export const NETWORK_AUTO_REFRESH_MS = 30_000
|
|||||||
export const networkKeys = {
|
export const networkKeys = {
|
||||||
all: ['network'] as const,
|
all: ['network'] as const,
|
||||||
peers: () => [...networkKeys.all, 'peers'] as const,
|
peers: () => [...networkKeys.all, 'peers'] as const,
|
||||||
|
discovered: () => [...networkKeys.all, 'discovered'] as const,
|
||||||
speakers: () => [...networkKeys.all, 'speakers'] as const,
|
speakers: () => [...networkKeys.all, 'speakers'] as const,
|
||||||
bird: () => [...networkKeys.all, 'bird'] as const,
|
bird: () => [...networkKeys.all, 'bird'] as const,
|
||||||
}
|
}
|
||||||
@@ -31,6 +35,14 @@ export function networkPeersQueryOptions() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function networkDiscoveredPeersQueryOptions() {
|
||||||
|
return queryOptions<PeerDiscoveriesResponse>({
|
||||||
|
queryKey: networkKeys.discovered(),
|
||||||
|
queryFn: () => apiJSON<PeerDiscoveriesResponse>('/v1/peers/discovered?status=pending'),
|
||||||
|
staleTime: 10_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function networkSpeakersQueryOptions() {
|
export function networkSpeakersQueryOptions() {
|
||||||
return queryOptions<SpeakersResponse>({
|
return queryOptions<SpeakersResponse>({
|
||||||
queryKey: networkKeys.speakers(),
|
queryKey: networkKeys.speakers(),
|
||||||
@@ -91,6 +103,39 @@ export function useDeletePeerMutation() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useApproveDiscoveredPeerMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body?: PeerDiscoveryApprove }) =>
|
||||||
|
apiMutate<{ peer: PeerRow; discovery: PeerDiscoveryRow }>(
|
||||||
|
`/v1/peers/discovered/${id}/approve`,
|
||||||
|
'POST',
|
||||||
|
body ?? {},
|
||||||
|
{ idempotent: false },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Пир одобрен')
|
||||||
|
invalidateNetwork(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить пира'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectDiscoveredPeerMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiMutate<PeerDiscoveryRow>(`/v1/peers/discovered/${id}/reject`, 'POST', {}, {
|
||||||
|
idempotent: false,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Пир отклонён')
|
||||||
|
invalidateNetwork(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отклонить пира'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateSpeakerMutation() {
|
export function useCreateSpeakerMutation() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const BIRD_SETTING_KEYS = [
|
|||||||
'bird_local_asn',
|
'bird_local_asn',
|
||||||
'bird_bgp_source_ipv4',
|
'bird_bgp_source_ipv4',
|
||||||
'bird_bgp_source_ipv6',
|
'bird_bgp_source_ipv6',
|
||||||
|
'peer_discovery_enabled',
|
||||||
|
'peer_discovery_ranges_v4',
|
||||||
|
'peer_discovery_ranges_v6',
|
||||||
|
'peer_discovery_require_external',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const
|
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const
|
||||||
@@ -38,7 +42,11 @@ export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
|||||||
'runtime_logs_max_file_mb',
|
'runtime_logs_max_file_mb',
|
||||||
])
|
])
|
||||||
|
|
||||||
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled'])
|
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||||
|
'runtime_logs_auto_enabled',
|
||||||
|
'peer_discovery_enabled',
|
||||||
|
'peer_discovery_require_external',
|
||||||
|
])
|
||||||
|
|
||||||
export const settingsKeys = {
|
export const settingsKeys = {
|
||||||
all: ['settings'] as const,
|
all: ['settings'] as const,
|
||||||
@@ -53,6 +61,11 @@ export function settingsQueryOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
||||||
|
if (BOOLEAN_SETTING_KEYS.has(key)) {
|
||||||
|
if (value === true || value === 1 || value === 'true' || value === '1') return 'true'
|
||||||
|
if (value === false || value === 0 || value === 'false' || value === '0') return 'false'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
if (NUMERIC_SETTING_KEYS.has(key)) {
|
if (NUMERIC_SETTING_KEYS.has(key)) {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||||
if (typeof value === 'string') return value
|
if (typeof value === 'string') return value
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
import { sessionCanManageApiKeys } from '@/lib/auth'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
||||||
|
|
||||||
@@ -21,11 +22,11 @@ export const Route = createFileRoute('/_auth/access')({
|
|||||||
function AccessComponent() {
|
function AccessComponent() {
|
||||||
const sessionQuery = useQuery(authSessionQueryOptions())
|
const sessionQuery = useQuery(authSessionQueryOptions())
|
||||||
const session = sessionQuery.data ?? null
|
const session = sessionQuery.data ?? null
|
||||||
const isOperator = session?.role === 'operator'
|
const canManageKeys = sessionCanManageApiKeys(session)
|
||||||
|
|
||||||
const keysQuery = useQuery({
|
const keysQuery = useQuery({
|
||||||
...apiKeysQueryOptions(),
|
...apiKeysQueryOptions(),
|
||||||
enabled: isOperator,
|
enabled: canManageKeys,
|
||||||
})
|
})
|
||||||
|
|
||||||
const keys = keysQuery.data ?? []
|
const keys = keysQuery.data ?? []
|
||||||
@@ -73,16 +74,31 @@ function AccessComponent() {
|
|||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void sessionQuery.refetch()
|
void sessionQuery.refetch()
|
||||||
if (isOperator) void keysQuery.refetch()
|
if (canManageKeys) void keysQuery.refetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionKindLabel =
|
||||||
|
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null
|
||||||
|
|
||||||
|
const sessionAccessLabel = (() => {
|
||||||
|
if (!session) return null
|
||||||
|
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
|
||||||
|
if (session.is_admin) return 'admin (portal)'
|
||||||
|
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
|
||||||
|
return session.permissions?.length
|
||||||
|
? session.permissions.slice(0, 3).join(', ')
|
||||||
|
: 'без access:admin'
|
||||||
|
}
|
||||||
|
return session.role || '—'
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Права доступа"
|
title="Права доступа"
|
||||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||||
actions={
|
actions={
|
||||||
isOperator ? (
|
canManageKeys ? (
|
||||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -94,36 +110,48 @@ function AccessComponent() {
|
|||||||
{session ? (
|
{session ? (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Текущая сессия"
|
title="Текущая сессия"
|
||||||
description="Tenant и роль ключа, с которым открыта панель."
|
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
|
||||||
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
|
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Tenant</p>
|
||||||
|
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Доступ</p>
|
||||||
|
<p className="font-mono text-xs">{sessionAccessLabel}</p>
|
||||||
|
</div>
|
||||||
|
{sessionKindLabel ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Tenant</p>
|
<p className="text-muted-foreground">Тип</p>
|
||||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
<p className="font-mono text-xs">{sessionKindLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{session.email ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Роль</p>
|
<p className="text-muted-foreground">Email</p>
|
||||||
<p className="font-mono">{session.role}</p>
|
<p className="break-all text-xs">{session.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
) : (
|
) : (
|
||||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||||
Не удалось определить сессию. Укажите токен в{' '}
|
Не удалось определить сессию. Укажите токен в{' '}
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
search={{ tab: 'connection' }}
|
search={{ tab: 'connection' }}
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
настройках
|
настройках
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||||
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOperator ? (
|
{canManageKeys ? (
|
||||||
<>
|
<>
|
||||||
{keysQuery.isLoading ? (
|
{keysQuery.isLoading ? (
|
||||||
<SectionCardsSkeleton count={3} />
|
<SectionCardsSkeleton count={3} />
|
||||||
@@ -140,10 +168,9 @@ function AccessComponent() {
|
|||||||
</>
|
</>
|
||||||
) : session ? (
|
) : session ? (
|
||||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
|
||||||
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
|
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
|
||||||
operator-ключом или создайте ключ через API / переменную{' '}
|
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||||||
<code className="text-xs">EVOBGP_API_KEYS</code>.
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,29 +5,32 @@ import { TabsContent } from '@evobgp/ui/components/tabs'
|
|||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import { NetworkDiscoveredPeersCard } from '@/components/network/network-discovered-peers-card'
|
||||||
import { NetworkKpi } from '@/components/network/network-kpi'
|
import { NetworkKpi } from '@/components/network/network-kpi'
|
||||||
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
||||||
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import {
|
import {
|
||||||
networkBirdQueryOptions,
|
networkBirdQueryOptions,
|
||||||
|
networkDiscoveredPeersQueryOptions,
|
||||||
networkPeersQueryOptions,
|
networkPeersQueryOptions,
|
||||||
networkSpeakersQueryOptions,
|
networkSpeakersQueryOptions,
|
||||||
} from '@/queries/network'
|
} from '@/queries/network'
|
||||||
|
|
||||||
type NetworkTab = 'peers' | 'speakers'
|
type NetworkTab = 'peers' | 'discovered' | 'speakers'
|
||||||
|
|
||||||
function parseNetworkTab(value: unknown): NetworkTab {
|
function parseNetworkTab(value: unknown): NetworkTab {
|
||||||
if (value === 'speakers') return 'speakers'
|
if (value === 'speakers') return 'speakers'
|
||||||
|
if (value === 'discovered') return 'discovered'
|
||||||
// legacy: overview | control-plane → peers
|
// legacy: overview | control-plane → peers
|
||||||
return 'peers'
|
return 'peers'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
|
* Network ops page — KPI (stats-12) + peers/discovered/speakers ResourcePage lists.
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* @see https://reui.io/preview/base/empty-state-12
|
* @see https://reui.io/preview/base/components/c-empty-1
|
||||||
*/
|
*/
|
||||||
export const Route = createFileRoute('/_auth/network')({
|
export const Route = createFileRoute('/_auth/network')({
|
||||||
component: NetworkComponent,
|
component: NetworkComponent,
|
||||||
@@ -40,16 +43,26 @@ function NetworkComponent() {
|
|||||||
const search = useSearch({ from: '/_auth/network' })
|
const search = useSearch({ from: '/_auth/network' })
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const discoveredQ = useQuery({
|
||||||
|
...networkDiscoveredPeersQueryOptions(),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
})
|
||||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
|
||||||
const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
|
const refreshing =
|
||||||
|
peersQ.isFetching ||
|
||||||
|
discoveredQ.isFetching ||
|
||||||
|
speakersQ.isFetching ||
|
||||||
|
birdQ.isFetching
|
||||||
const peers = peersQ.data?.items ?? []
|
const peers = peersQ.data?.items ?? []
|
||||||
|
const discovered = discoveredQ.data?.items ?? []
|
||||||
const speakers = speakersQ.data?.items ?? []
|
const speakers = speakersQ.data?.items ?? []
|
||||||
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void peersQ.refetch()
|
void peersQ.refetch()
|
||||||
|
void discoveredQ.refetch()
|
||||||
void speakersQ.refetch()
|
void speakersQ.refetch()
|
||||||
void birdQ.refetch()
|
void birdQ.refetch()
|
||||||
}
|
}
|
||||||
@@ -58,7 +71,7 @@ function NetworkComponent() {
|
|||||||
<div className="flex flex-col gap-4 md:gap-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сеть"
|
title="Сеть"
|
||||||
description="BGP-пиры, спикеры и статус BIRD"
|
description="BGP-пиры, автообнаружение и спикеры"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
||||||
@@ -81,6 +94,7 @@ function NetworkComponent() {
|
|||||||
}
|
}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'peers', label: 'Пиры', count: peers.length },
|
{ id: 'peers', label: 'Пиры', count: peers.length },
|
||||||
|
{ id: 'discovered', label: 'На одобрение', count: discovered.length },
|
||||||
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
|
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -95,6 +109,17 @@ function NetworkComponent() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="discovered" className="mt-4">
|
||||||
|
<NetworkDiscoveredPeersCard
|
||||||
|
items={discovered}
|
||||||
|
speakers={speakers}
|
||||||
|
isLoading={discoveredQ.isLoading}
|
||||||
|
isError={discoveredQ.isError}
|
||||||
|
error={discoveredQ.error}
|
||||||
|
onRetry={() => void discoveredQ.refetch()}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="speakers" className="mt-4">
|
<TabsContent value="speakers" className="mt-4">
|
||||||
<NetworkSpeakersCard
|
<NetworkSpeakersCard
|
||||||
items={speakers}
|
items={speakers}
|
||||||
|
|||||||
@@ -54,6 +54,23 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
|||||||
bird_local_asn: 'Локальный ASN',
|
bird_local_asn: 'Локальный ASN',
|
||||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||||
bird_bgp_source_ipv6: 'BGP source IPv6',
|
bird_bgp_source_ipv6: 'BGP source IPv6',
|
||||||
|
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||||
|
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
|
||||||
|
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
|
||||||
|
peer_discovery_require_external: 'Только external ASN',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIRD_BOOL_ITEMS = [
|
||||||
|
{ value: 'true', label: 'Вкл' },
|
||||||
|
{ value: 'false', label: 'Выкл' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||||
|
peer_discovery_enabled:
|
||||||
|
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
|
||||||
|
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||||
|
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||||
|
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
|
||||||
}
|
}
|
||||||
|
|
||||||
function TenantSettingsComponent() {
|
function TenantSettingsComponent() {
|
||||||
@@ -143,17 +160,32 @@ function TenantSettingsComponent() {
|
|||||||
<SettingsSettingField
|
<SettingsSettingField
|
||||||
key={key}
|
key={key}
|
||||||
title={BIRD_LABELS[key]}
|
title={BIRD_LABELS[key]}
|
||||||
description={key}
|
description={BIRD_HINTS[key] ?? key}
|
||||||
labelFor={key}
|
labelFor={key}
|
||||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||||
>
|
>
|
||||||
<Input
|
{key === 'peer_discovery_enabled' ||
|
||||||
id={key}
|
key === 'peer_discovery_require_external' ? (
|
||||||
value={birdForm[key] ?? ''}
|
<SelectField
|
||||||
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
|
id={key}
|
||||||
placeholder={BIRD_LABELS[key]}
|
items={[...BIRD_BOOL_ITEMS]}
|
||||||
/>
|
value={birdForm[key] || 'false'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||||
|
}
|
||||||
|
placeholder="Выкл"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
value={birdForm[key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={BIRD_LABELS[key]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSettingField>
|
</SettingsSettingField>
|
||||||
))}
|
))}
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
|
|||||||
+38
-41
@@ -221,10 +221,36 @@ export type BgpPeerCreate = {
|
|||||||
}
|
}
|
||||||
export type BgpPeerPatch = Partial<BgpPeerCreate>
|
export type BgpPeerPatch = Partial<BgpPeerCreate>
|
||||||
|
|
||||||
|
export type PeerDiscoveryRow = {
|
||||||
|
id: string
|
||||||
|
speaker_id?: string | null
|
||||||
|
neighbor_id?: string
|
||||||
|
neighbor: string
|
||||||
|
remote_asn?: number
|
||||||
|
protocol_name?: string
|
||||||
|
session_state?: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | string
|
||||||
|
first_seen_at?: string
|
||||||
|
last_seen_at?: string
|
||||||
|
approved_peer_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeerDiscoveriesResponse = {
|
||||||
|
items: PeerDiscoveryRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeerDiscoveryApprove = {
|
||||||
|
name?: string
|
||||||
|
bgp_speaker_id?: string | null
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Speakers ----
|
// ---- Speakers ----
|
||||||
export type BgpSessionLive = {
|
export type BgpSessionLive = {
|
||||||
name: string
|
name: string
|
||||||
neighbor?: string
|
neighbor?: string
|
||||||
|
neighbor_as?: number
|
||||||
|
neighbor_id?: string
|
||||||
state: string
|
state: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,13 +367,20 @@ export type JobsResponse = Page<JobRow>
|
|||||||
export type AppSettings = Record<string, unknown>
|
export type AppSettings = Record<string, unknown>
|
||||||
|
|
||||||
// ---- Auth / API keys ----
|
// ---- Auth / API keys ----
|
||||||
|
export type ApiKeyRole = 'viewer' | 'editor' | 'operator' | 'node'
|
||||||
|
|
||||||
|
/** GET /v1/auth/session — API key has role; portal JWT uses kind/permissions/is_admin. */
|
||||||
export type AuthSession = {
|
export type AuthSession = {
|
||||||
tenant_id: string
|
tenant_id: string
|
||||||
role: 'viewer' | 'editor' | 'operator' | 'node'
|
/** API-key role; empty for portal JWT sessions. */
|
||||||
|
role: ApiKeyRole | ''
|
||||||
|
kind?: 'apikey' | 'jwt'
|
||||||
|
user_id?: string
|
||||||
|
email?: string
|
||||||
|
permissions?: string[]
|
||||||
|
is_admin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiKeyRole = AuthSession['role']
|
|
||||||
|
|
||||||
export type ApiKey = {
|
export type ApiKey = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -375,41 +408,5 @@ export type AsyncJobAccepted = {
|
|||||||
job_id: string
|
job_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Firewall blocklist ----
|
// Firewall blocklist types removed — the firewall subsystem moved to the standalone
|
||||||
export type FirewallClient = {
|
// EvoFirewall service. See docs/firewall.md.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -42,6 +42,7 @@ func main() {
|
|||||||
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
||||||
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
||||||
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
||||||
|
AuditIngestSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_AUDIT_INGEST_SECRET"), os.Getenv("AUTH_AUDIT_INGEST_SECRET")),
|
||||||
}
|
}
|
||||||
srv, err := httpapi.New(opts)
|
srv, err := httpapi.New(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func main() {
|
|||||||
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
||||||
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
||||||
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
||||||
|
AuditIngestSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_AUDIT_INGEST_SECRET"), os.Getenv("AUTH_AUDIT_INGEST_SECRET")),
|
||||||
}
|
}
|
||||||
srv, err := httpapi.New(opts)
|
srv, err := httpapi.New(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,3 +15,10 @@ WEBUI_DOMAIN=bgp.example.com
|
|||||||
WEBUI_IP_WHITELIST=203.0.113.10/32
|
WEBUI_IP_WHITELIST=203.0.113.10/32
|
||||||
LETSENCRYPT_EMAIL=[email protected]
|
LETSENCRYPT_EMAIL=[email protected]
|
||||||
CF_DNS_API_TOKEN=
|
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
|
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
|
||||||
# Защищенные сервисы, которые updater никогда не перезапускает
|
# Защищенные сервисы, которые updater никогда не перезапускает
|
||||||
AUTO_UPDATE_PROTECTED_SERVICES=bird2
|
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_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||||
EVOBGP_SERVICE: evobgp-all
|
EVOBGP_SERVICE: evobgp-all
|
||||||
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
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"
|
EVOBGP_DEV_INSECURE: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- bird_etc:/etc/bird
|
- bird_etc:/etc/bird
|
||||||
|
|||||||
@@ -138,6 +138,12 @@ services:
|
|||||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||||
EVOBGP_SERVICE: evobgp-all
|
EVOBGP_SERVICE: evobgp-all
|
||||||
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
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).
|
# DEV ONLY — не для production (см. docs/access.md).
|
||||||
EVOBGP_DEV_INSECURE: "1"
|
EVOBGP_DEV_INSECURE: "1"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+33
-12
@@ -12,7 +12,24 @@
|
|||||||
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
|
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
|
||||||
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
|
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
|
||||||
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
|
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
|
||||||
| `EVOBGP_PORTAL_TENANT_ID` | Tenant для всех portal JWT (обязателен при JWT) |
|
| `AUTH_AUDIT_INGEST_SECRET` / `EVOBGP_AUTH_AUDIT_INGEST_SECRET` | Shared secret для push CRUD audit в auth-portal (`POST /api/v1/ingest/audit`, `source_app=bgp`) |
|
||||||
|
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
|
||||||
|
|
||||||
|
Источник tenant (по приоритету):
|
||||||
|
|
||||||
|
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
|
||||||
|
2. Env `EVOBGP_PORTAL_TENANT_ID`
|
||||||
|
|
||||||
|
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
|
||||||
|
|
||||||
|
`VITE_AUTH_*` в runtime `.env` **не** меняют уже собранный `evobgp-web` образ. UI берёт режим из `GET /v1/auth/config` (`required` ← `AUTH_REQUIRED`, `portal_url` ← `AUTH_PORTAL_URL`).
|
||||||
|
|
||||||
|
Проверка после рестарта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS https://bgp.shnt.top/v1/auth/config
|
||||||
|
# {"required":true,"portal_url":"https://auth.shnt.top"}
|
||||||
|
```
|
||||||
|
|
||||||
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
|
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
|
||||||
|
|
||||||
@@ -44,14 +61,18 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
|||||||
|
|
||||||
### Управление через API и UI
|
### Управление через API и UI
|
||||||
|
|
||||||
При подключённой БД operator может:
|
При подключённой БД управлять ключами может:
|
||||||
|
|
||||||
- `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
|
- API-ключ с ролью **`operator`**, или
|
||||||
- В веб-панели: **Права доступа** (`/access`) → блок «API-ключи» (только для роли `operator`). Токен для браузера — в **Настройки** (`/settings`).
|
- portal JWT с **`is_admin`** / правом **`bgp:access:admin`** (админ auth-portal).
|
||||||
|
|
||||||
|
Эндпоинты: `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
|
||||||
|
|
||||||
|
В веб-панели: **Права доступа** (`/access`) → блок «API-ключи». Токен для браузера (API-key gate) — в **Настройки** (`/settings`).
|
||||||
|
|
||||||
Полный токен возвращается **один раз** в ответе `201` (создание) и `200` (ротация). В списках — только `prefix` (первые 8 символов). В БД хранится SHA-256 токена, не plaintext.
|
Полный токен возвращается **один раз** в ответе `201` (создание) и `200` (ротация). В списках — только `prefix` (первые 8 символов). В БД хранится SHA-256 токена, не plaintext.
|
||||||
|
|
||||||
`GET /v1/auth/session` — текущие `tenant_id` и `role` (для UI).
|
`GET /v1/auth/session` — `tenant_id`, `kind` (`apikey`|`jwt`), для API-ключа — `role`; для JWT — `user_id`, `email`, `permissions`, `is_admin`.
|
||||||
|
|
||||||
### Роли
|
### Роли
|
||||||
|
|
||||||
@@ -162,13 +183,13 @@ http://localhost:5173,http://127.0.0.1:5173,https://ui.example.com
|
|||||||
|
|
||||||
## Краткая матрица (ориентир)
|
## Краткая матрица (ориентир)
|
||||||
|
|
||||||
| Действие | viewer | editor | operator | node |
|
| Действие | viewer | editor | operator | node | portal admin / `bgp:access:admin` |
|
||||||
|----------|--------|--------|----------|------|
|
|----------|--------|--------|----------|------|-----------------------------------|
|
||||||
| GET модули, ревизии, peers, speakers | да | да | да | нет |
|
| GET модули, ревизии, peers, speakers | да | да | да | нет | по permissions |
|
||||||
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет |
|
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет | по permissions |
|
||||||
| apply, rollback, PATCH settings | нет | нет | да | нет |
|
| apply, rollback, PATCH settings | нет | нет | да | нет | `bgp:operations:admin` |
|
||||||
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет |
|
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет | да |
|
||||||
| bundle, latest revision, enroll | нет | нет | нет | да |
|
| bundle, latest revision, enroll | нет | нет | нет | да | нет |
|
||||||
|
|
||||||
Точные проверки по каждому маршруту — в коде `internal/httpapi` и в схеме безопасности операций в OpenAPI.
|
Точные проверки по каждому маршруту — в коде `internal/httpapi` и в схеме безопасности операций в OpenAPI.
|
||||||
|
|
||||||
|
|||||||
+10
@@ -116,6 +116,16 @@
|
|||||||
|
|
||||||
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
|
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
|
||||||
|
|
||||||
|
## CRUD audit (`/v1/audit`)
|
||||||
|
|
||||||
|
Локальный журнал изменений CRUD (modules, peers, settings, API keys, …). Миграция `000030_audit_log`. Чтение — `bgp:monitoring:read` (viewer+).
|
||||||
|
|
||||||
|
| Метод | Путь | Роль | Назначение |
|
||||||
|
|-------|------|------|------------|
|
||||||
|
| `GET` | `/v1/audit` | viewer+ | Пагинированный audit (`cursor`, `limit`, опционально `action`, `severity`) |
|
||||||
|
|
||||||
|
При `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` каждая запись дополнительно отправляется в auth-portal (`POST /api/v1/ingest/audit`, `source_app=bgp`).
|
||||||
|
|
||||||
## Соглашения из OpenAPI
|
## Соглашения из OpenAPI
|
||||||
|
|
||||||
- Ошибки в стиле **RFC 9457** (`application/problem+json`): `type`, `title`, `status`, `detail`, и т.д.
|
- Ошибки в стиле **RFC 9457** (`application/problem+json`): `type`, `title`, `status`, `detail`, и т.д.
|
||||||
|
|||||||
+9
-46
@@ -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_*` локально.
|
Таблицы `firewall_client` / `firewall_rule` в БД оставлены (не удаляются миграциями) для истории; API/UI/scripts больше не обслуживают их.
|
||||||
2. **Approve** — operator в Web UI (`/firewall` → Запросы).
|
|
||||||
3. **Sync** — `GET /v1/firewall/blocklist` с `Authorization: Bearer <client_token>`.
|
|
||||||
|
|
||||||
## Политика block/accept
|
## Миграция клиентов
|
||||||
|
|
||||||
- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist.
|
1. Разверните EvoFirewall (auth-portal app id `fw`).
|
||||||
- **`accept`** — не блокировать префиксы этого community.
|
2. Переустановите агенты one-liner'ом EvoFirewall (`/v1/agent/install.sh`).
|
||||||
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
|
3. Для списков по community создайте IP list type `evobgp_community` и укажите `EVOBGP_API_URL` + token в настройках EvoFirewall.
|
||||||
- **Default** — accept (пустой blocklist без явных `block`).
|
|
||||||
|
|
||||||
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
|
Старые токены `evobgp_fw_*` **не** переносятся — только re-enroll.
|
||||||
|
|
||||||
Справочник 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).
|
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
### Настройки (`/v1/settings`)
|
### Настройки (`/v1/settings`)
|
||||||
- KV c ключами BIRD и дополнительными feature flags.
|
- KV c ключами BIRD и дополнительными feature flags.
|
||||||
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
|
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
|
||||||
|
- **Автообнаружение пиров (peer discovery):**
|
||||||
|
- `peer_discovery_enabled` (bool) — генерирует в `evobgp_peers.conf` dynamic BGP listener (`neighbor range` + `import none` / `export none`).
|
||||||
|
- `peer_discovery_ranges_v4` / `peer_discovery_ranges_v6` — CIDR через пробел/запятую (обязательны при enabled).
|
||||||
|
- `peer_discovery_require_external` (bool, default true) — `neighbor range … external`.
|
||||||
|
- Live-сессии `evobgp_dyn_*` попадают в `GET /v1/peers/discovered`; оператор **одобряет** (`POST …/approve` → обычный `bgp_peer` + `peer_reconcile`) или **отклоняет**.
|
||||||
|
- Идентичность pending: **Neighbor ID** (BGP Identifier), иначе `neighbor+ASN`.
|
||||||
|
- UI: Сеть → вкладка «На одобрение»; настройки — Параметры → BIRD.
|
||||||
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
|
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
|
||||||
|
|
||||||
### Web UI: настройки tenant и интерфейса
|
### Web UI: настройки tenant и интерфейса
|
||||||
|
|||||||
+308
-3
@@ -13,7 +13,7 @@ info:
|
|||||||
|
|
||||||
**Аутентификация (dual):**
|
**Аутентификация (dual):**
|
||||||
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
|
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
|
||||||
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `EVOBGP_PORTAL_TENANT_ID`.
|
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
|
||||||
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
|
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
|
||||||
|
|
||||||
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
||||||
@@ -59,6 +59,8 @@ tags:
|
|||||||
description: Сессия текущего API-ключа (tenant и роль).
|
description: Сессия текущего API-ключа (tenant и роль).
|
||||||
- name: Monitoring
|
- name: Monitoring
|
||||||
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
|
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
|
||||||
|
- name: Audit
|
||||||
|
description: Журнал CRUD-изменений tenant (локально + опциональный push в auth-portal). Чтение — bgp:monitoring:read.
|
||||||
- name: Maintenance
|
- name: Maintenance
|
||||||
description: Политики обслуживания PostgreSQL (instance-scoped). CRUD и запуск — operator.
|
description: Политики обслуживания PostgreSQL (instance-scoped). CRUD и запуск — operator.
|
||||||
- name: RuntimeLogs
|
- name: RuntimeLogs
|
||||||
@@ -676,13 +678,32 @@ components:
|
|||||||
|
|
||||||
AuthSession:
|
AuthSession:
|
||||||
type: object
|
type: object
|
||||||
required: [tenant_id, role]
|
required: [tenant_id, kind]
|
||||||
properties:
|
properties:
|
||||||
tenant_id:
|
tenant_id:
|
||||||
$ref: "#/components/schemas/ResourceId"
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [apikey, jwt]
|
||||||
|
description: apikey — Bearer API key; jwt — portal SSO token.
|
||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum: [viewer, editor, operator, node]
|
description: >
|
||||||
|
API-key role (viewer|editor|operator|node). Empty string for portal JWT sessions.
|
||||||
|
user_id:
|
||||||
|
type: string
|
||||||
|
description: JWT sub (portal sessions only).
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: JWT email claim (portal sessions only).
|
||||||
|
permissions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: JWT permissions (bgp:*); portal sessions only.
|
||||||
|
is_admin:
|
||||||
|
type: boolean
|
||||||
|
description: Portal is_admin claim; grants all bgp permissions.
|
||||||
|
|
||||||
ApiKey:
|
ApiKey:
|
||||||
type: object
|
type: object
|
||||||
@@ -1009,6 +1030,12 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
neighbor:
|
neighbor:
|
||||||
type: string
|
type: string
|
||||||
|
neighbor_as:
|
||||||
|
type: integer
|
||||||
|
description: Remote ASN from birdc (`Neighbor AS:`).
|
||||||
|
neighbor_id:
|
||||||
|
type: string
|
||||||
|
description: BGP Identifier / Neighbor ID from birdc (`Neighbor ID:`).
|
||||||
state:
|
state:
|
||||||
type: string
|
type: string
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -1278,6 +1305,70 @@ components:
|
|||||||
has_more:
|
has_more:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
|
AuditSeverity:
|
||||||
|
type: string
|
||||||
|
enum: [info, warning, critical]
|
||||||
|
|
||||||
|
AuditLogEntry:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
[id, tenant_id, event_id, source_app, action, severity, summary, created_at]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
tenant_id:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
description: Stable id for portal ingest deduplication (prefix bgp-).
|
||||||
|
source_app:
|
||||||
|
type: string
|
||||||
|
enum: [bgp]
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
description: Machine action key (e.g. bgp.module.create).
|
||||||
|
severity:
|
||||||
|
$ref: "#/components/schemas/AuditSeverity"
|
||||||
|
actor_user_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
actor_email:
|
||||||
|
type: ["string", "null"]
|
||||||
|
actor_name:
|
||||||
|
type: ["string", "null"]
|
||||||
|
actor_api_key_prefix:
|
||||||
|
type: ["string", "null"]
|
||||||
|
target_type:
|
||||||
|
type: ["string", "null"]
|
||||||
|
enum: [app_resource, null]
|
||||||
|
target_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
details:
|
||||||
|
type: ["object", "null"]
|
||||||
|
additionalProperties: true
|
||||||
|
ip:
|
||||||
|
type: ["string", "null"]
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
portal_pushed_at:
|
||||||
|
type: ["string", "null"]
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
AuditLogList:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AuditLogEntry"
|
||||||
|
next_cursor:
|
||||||
|
type: string
|
||||||
|
has_more:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
RuntimeLogAutoPolicy:
|
RuntimeLogAutoPolicy:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1630,6 +1721,51 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
|
BgpPeerDiscovery:
|
||||||
|
type: object
|
||||||
|
required: [id, neighbor, status]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
speaker_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
neighbor_id:
|
||||||
|
type: string
|
||||||
|
description: BGP Identifier (Neighbor ID / router ID) from birdc.
|
||||||
|
neighbor:
|
||||||
|
type: string
|
||||||
|
description: Neighbor IP address.
|
||||||
|
remote_asn:
|
||||||
|
type: integer
|
||||||
|
protocol_name:
|
||||||
|
type: string
|
||||||
|
description: BIRD protocol name (evobgp_dyn_*).
|
||||||
|
session_state:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [pending, approved, rejected]
|
||||||
|
first_seen_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_seen_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
approved_peer_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
BgpPeerDiscoveryApprove:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
bgp_speaker_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
BgpPeerCreate:
|
BgpPeerCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [neighbor, remote_asn]
|
required: [neighbor, remote_asn]
|
||||||
@@ -2837,6 +2973,57 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/communities/{id}/prefixes:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/CommunityId"
|
||||||
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 500
|
||||||
|
maximum: 5000
|
||||||
|
get:
|
||||||
|
tags: [Communities]
|
||||||
|
summary: Префиксы community (latest revision per module)
|
||||||
|
description: |
|
||||||
|
Уникальные materialized-префиксы с данным community_id
|
||||||
|
из последней ревизии каждого модуля tenant.
|
||||||
|
Поле `prefixes` — плоский список для клиентов вроде EvoFirewall.
|
||||||
|
operationId: listCommunityPrefixes
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [items, has_more]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [prefix]
|
||||||
|
properties:
|
||||||
|
prefix:
|
||||||
|
type: string
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
prefixes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
next_cursor:
|
||||||
|
type: ["string", "null"]
|
||||||
|
has_more:
|
||||||
|
type: boolean
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/communities/{id}:
|
/v1/communities/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/TenantId"
|
- $ref: "#/components/parameters/TenantId"
|
||||||
@@ -2964,6 +3151,90 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/peers/discovered:
|
||||||
|
get:
|
||||||
|
tags: [Peers]
|
||||||
|
summary: Список обнаруженных (неодобренных) пиров
|
||||||
|
description: >
|
||||||
|
Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`.
|
||||||
|
По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending.
|
||||||
|
operationId: listDiscoveredPeers
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pending, approved, rejected, all]
|
||||||
|
default: pending
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/BgpPeerDiscovery"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/peers/discovered/{id}/approve:
|
||||||
|
post:
|
||||||
|
tags: [Peers]
|
||||||
|
summary: Одобрить обнаруженного пира
|
||||||
|
description: >
|
||||||
|
Создаёт обычный `bgp_peer` из discovery-записи и запускает `peer_reconcile`.
|
||||||
|
operationId: approveDiscoveredPeer
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/PeerId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BgpPeerDiscoveryApprove"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Пир создан, discovery → approved.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
peer:
|
||||||
|
$ref: "#/components/schemas/BgpPeer"
|
||||||
|
discovery:
|
||||||
|
$ref: "#/components/schemas/BgpPeerDiscovery"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/peers/discovered/{id}/reject:
|
||||||
|
post:
|
||||||
|
tags: [Peers]
|
||||||
|
summary: Отклонить обнаруженного пира
|
||||||
|
description: Помечает discovery как rejected; повторно не всплывает при sync.
|
||||||
|
operationId: rejectDiscoveredPeer
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/PeerId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Discovery → rejected.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BgpPeerDiscovery"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/peers/{id}:
|
/v1/peers/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/TenantId"
|
- $ref: "#/components/parameters/TenantId"
|
||||||
@@ -4501,6 +4772,40 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/audit:
|
||||||
|
get:
|
||||||
|
tags: [Audit]
|
||||||
|
summary: Журнал CRUD audit tenant
|
||||||
|
description: |
|
||||||
|
Локальный журнал изменений (modules, peers, settings, API keys и т.д.).
|
||||||
|
При настроенных `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` события также
|
||||||
|
отправляются в auth-portal ingest (`source_app=bgp`).
|
||||||
|
operationId: listAuditLog
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Limit"
|
||||||
|
- name: action
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Filter by action prefix/key (exact match).
|
||||||
|
- name: severity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AuditSeverity"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AuditLogList"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/settings:
|
/v1/settings:
|
||||||
get:
|
get:
|
||||||
tags: [Settings]
|
tags: [Settings]
|
||||||
|
|||||||
+49
-30
@@ -1,100 +1,119 @@
|
|||||||
# UI Design Contract (ops apps)
|
# 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
|
## Surface
|
||||||
|
|
||||||
|
Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// apps/web/src/lib/ui-surface.ts
|
// apps/web/src/lib/ui-surface.ts (где есть)
|
||||||
export const UI_SURFACE = 'frame' as const
|
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
|
## Canonical PRO references
|
||||||
|
|
||||||
| Зона | Block | Preview |
|
| Зона | Block | Preview |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
|
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
|
||||||
| KPI | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | https://reui.io/preview/base/stats-12 |
|
| KPI | horizontal compact hybrid (EvoBGP SoT: icon left + label/Badge + value ± variant) | https://reui.io/preview/base/stats-12 |
|
||||||
|
| Quick Actions | Frame tiles (sibling KPI) + Badge «Перейти» | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
|
||||||
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
|
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
|
||||||
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
| 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 |
|
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||||
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
||||||
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||||
| Lookup | `/lookup` — Frame form + `KpiStatGrid` + DataGrid | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/empty-state-2 |
|
|
||||||
|
|
||||||
## Kit API (`reui-kit/`)
|
## Kit API (`reui-kit/`)
|
||||||
|
|
||||||
| Component | Role |
|
| Component | Role |
|
||||||
|-----------|------|
|
|-----------|------|
|
||||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||||
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
| `KpiStatGrid` | EvoBGP hybrid KPI tiles (`items`/`cards`, `variant`, Badge) |
|
||||||
| `QuickActionGrid` | KPI-like quick action tiles under KPI (gated by `ui_show_quick_actions`) |
|
| `QuickActionGrid` | KPI-like quick action tiles (gated by `showQuickActions`) |
|
||||||
| `OpsDashboard` | KPI + charts + attention queue |
|
| `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue |
|
||||||
| `SettingsShell` | settings nav + Outlet |
|
| `SettingsShell` | settings nav + Outlet |
|
||||||
| `DetailPanel` | detail Frame sections |
|
| `DetailPanel` | detail Frame sections |
|
||||||
| `filter-utils` | apply/clear ReUI Filters |
|
| `filter-utils` | apply/clear ReUI Filters |
|
||||||
|
|
||||||
|
`KpiStatGrid` / `QuickActionGrid` markup — SoT **EvoBGP**; в остальных apps diff только `@scope/ui` imports.
|
||||||
|
|
||||||
## Dashboard layout
|
## Dashboard layout
|
||||||
|
|
||||||
| App | Section order |
|
| App | Section order |
|
||||||
|-----|---------------|
|
|-----|---------------|
|
||||||
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
|
| EvoBGP / CFDM / EvoFirewall | KPI → **QuickActionGrid** → charts / rest |
|
||||||
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
|
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
|
||||||
|
| auth-portal | portal-specific; Quick Actions при наличии dashboard |
|
||||||
|
|
||||||
Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings` (default `true`).
|
Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`).
|
||||||
|
|
||||||
## Shared App Shell chrome
|
## Shared App Shell chrome
|
||||||
|
|
||||||
Эталон: **EvoBGP** production [`apps/web/src/components/layout/app-shell.tsx`](../apps/web/src/components/layout/app-shell.tsx) + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
|
Эталон разметки: production apps + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
|
||||||
|
При переключении между apps меняются **только** sidebar nav labels/hrefs и `main` content.
|
||||||
При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav labels/hrefs и `main` content. Разметка, ширина, фон и hover chrome идентичны.
|
|
||||||
|
|
||||||
| Токен / зона | Значение |
|
| Токен / зона | Значение |
|
||||||
|--------------|----------|
|
|--------------|----------|
|
||||||
| `SIDEBAR_WIDTH` / `--sidebar-width` | `240px` (в `packages/ui` sidebar + Provider style) |
|
| `SIDEBAR_WIDTH` / `--sidebar-width` | `240px` |
|
||||||
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css` — **без** AppShell `color-mix` override |
|
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` — **без** AppShell `color-mix` override |
|
||||||
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
|
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
|
||||||
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
|
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
|
||||||
| Header right | **AppsMenu** → **SystemMonitorPopover** → **ModeToggle** (без Search в chrome) |
|
| Header right | **AppsMenu** → **SystemMonitorPopover** (тема — в NavUser) |
|
||||||
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4` → **пустой** `SidebarFooter` |
|
| Sidebar | AppSwitcher → groups → icons `size-4` → **NavUser** в `SidebarFooter` |
|
||||||
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
|
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
|
||||||
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
|
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
|
||||||
|
|
||||||
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
|
Запрещено в chrome: `SidebarRail`, sync-row footer, Search pill в header, issues Badge в header, `ModeToggle` в header (тема только в NavUser), Provider `color-mix` для `--sidebar*`.
|
||||||
|
|
||||||
App Switcher: source of truth — auth-portal `GET /api/v1/app-switcher`. Id: `bgp`. Admin: portal `/admin/apps`.
|
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
|
||||||
|
|
||||||
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
|
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
|
||||||
|
|
||||||
|
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
|
||||||
|
|
||||||
## System monitor
|
## System monitor
|
||||||
|
|
||||||
`SystemMonitorPopover` in app-shell header next to `ModeToggle` (после AppsMenu). Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
|
`SystemMonitorPopover` in header after AppsMenu. Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
|
||||||
|
|
||||||
## MCP workflow
|
## 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`
|
2. Cite `previewUrl` + `docsUrl`
|
||||||
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
|
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`
|
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
|
## Spacing
|
||||||
|
|
||||||
- AppShell main: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` (shared chrome)
|
- AppShell main / PageShell: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5`
|
||||||
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
|
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
|
||||||
- Max 1 primary CTA per screen
|
- 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
|
## Forbidden
|
||||||
|
|
||||||
- Card as ops list/dashboard shell
|
- Card as ops list/dashboard shell
|
||||||
- Hand-rolled data tables when ReUI DataGrid exists
|
- Hand-rolled data tables when ReUI DataGrid / `ResourcePage` exists
|
||||||
- Hand-rolled KPI grids when `KpiStatGrid` 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
|
- Mixing Card and Frame surfaces on one ops screen
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Package audit pushes local audit events to auth-portal ingest API.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/httpclient"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ingestPath = "/api/v1/ingest/audit"
|
||||||
|
|
||||||
|
// PortalPusher sends audit rows to auth-portal (best-effort, async-friendly).
|
||||||
|
type PortalPusher struct {
|
||||||
|
BaseURL string
|
||||||
|
Secret string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
MarkPushed func(id string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushEvent posts one audit entry to portal ingest.
|
||||||
|
func (p *PortalPusher) PushEvent(ctx context.Context, entry *store.AuditEntry) error {
|
||||||
|
if p == nil || entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/")
|
||||||
|
secret := strings.TrimSpace(p.Secret)
|
||||||
|
if base == "" || secret == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hc := p.HTTPClient
|
||||||
|
if hc == nil {
|
||||||
|
hc = httpclient.New(15 * time.Second)
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"events": []map[string]any{p.eventPayload(entry)},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("audit: marshal ingest: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+ingestPath, bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("audit: portal ingest: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return fmt.Errorf("audit: portal ingest %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
if p.MarkPushed != nil {
|
||||||
|
if err := p.MarkPushed(entry.ID); err != nil {
|
||||||
|
log.Printf("audit: mark portal pushed id=%s: %v", entry.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PortalPusher) eventPayload(entry *store.AuditEntry) map[string]any {
|
||||||
|
ev := map[string]any{
|
||||||
|
"event_id": entry.EventID,
|
||||||
|
"source_app": store.AuditSourceAppBGP,
|
||||||
|
"action": entry.Action,
|
||||||
|
"severity": entry.Severity,
|
||||||
|
"summary": entry.Summary,
|
||||||
|
"created_at": entry.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if entry.ActorUserID != "" {
|
||||||
|
ev["actor_user_id"] = entry.ActorUserID
|
||||||
|
} else {
|
||||||
|
ev["actor_user_id"] = nil
|
||||||
|
}
|
||||||
|
if entry.ActorEmail != "" {
|
||||||
|
ev["actor_email"] = entry.ActorEmail
|
||||||
|
} else {
|
||||||
|
ev["actor_email"] = nil
|
||||||
|
}
|
||||||
|
if entry.ActorName != "" {
|
||||||
|
ev["actor_name"] = entry.ActorName
|
||||||
|
} else {
|
||||||
|
ev["actor_name"] = nil
|
||||||
|
}
|
||||||
|
if entry.TargetType != "" {
|
||||||
|
ev["target_type"] = entry.TargetType
|
||||||
|
} else {
|
||||||
|
ev["target_type"] = nil
|
||||||
|
}
|
||||||
|
if entry.TargetID != "" {
|
||||||
|
ev["target_id"] = entry.TargetID
|
||||||
|
} else {
|
||||||
|
ev["target_id"] = nil
|
||||||
|
}
|
||||||
|
if entry.Details != nil {
|
||||||
|
ev["details"] = entry.Details
|
||||||
|
} else {
|
||||||
|
ev["details"] = nil
|
||||||
|
}
|
||||||
|
if entry.IP != "" {
|
||||||
|
ev["ip"] = entry.IP
|
||||||
|
} else {
|
||||||
|
ev["ip"] = nil
|
||||||
|
}
|
||||||
|
return ev
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPortalPusherPushEvent(t *testing.T) {
|
||||||
|
var got struct {
|
||||||
|
Events []map[string]any `json:"events"`
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != ingestPath {
|
||||||
|
t.Fatalf("path=%s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-secret" {
|
||||||
|
t.Fatalf("auth=%q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]int{"accepted": 1, "duplicates": 0})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
marked := false
|
||||||
|
p := &PortalPusher{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Secret: "test-secret",
|
||||||
|
MarkPushed: func(id string) error {
|
||||||
|
marked = id == "local-id"
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entry := &store.AuditEntry{
|
||||||
|
ID: "local-id",
|
||||||
|
EventID: "bgp-test-event",
|
||||||
|
Action: "bgp.module.create",
|
||||||
|
Severity: store.AuditSeverityInfo,
|
||||||
|
Summary: "Created module",
|
||||||
|
SourceApp: store.AuditSourceAppBGP,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
TargetType: store.AuditTargetAppResource,
|
||||||
|
TargetID: "mod-1",
|
||||||
|
}
|
||||||
|
if err := p.PushEvent(context.Background(), entry); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Events) != 1 {
|
||||||
|
t.Fatalf("events=%d", len(got.Events))
|
||||||
|
}
|
||||||
|
if got.Events[0]["source_app"] != "bgp" {
|
||||||
|
t.Fatalf("source_app=%v", got.Events[0]["source_app"])
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
t.Fatal("expected mark pushed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package birdfmt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parent protocol names for discovery listeners (not spawned session names).
|
||||||
|
const (
|
||||||
|
DiscoveryProtocolV4 = "evobgp_discover_v4"
|
||||||
|
DiscoveryProtocolV6 = "evobgp_discover_v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DynamicBGPDiscoveryOptions configures quarantine dynamic BGP listeners.
|
||||||
|
type DynamicBGPDiscoveryOptions struct {
|
||||||
|
RangesV4 []string // CIDR prefixes
|
||||||
|
RangesV6 []string
|
||||||
|
RequireExternal bool // neighbor range … external (default true)
|
||||||
|
DynamicNameDigits int // default 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderDynamicBGPDiscovery renders passive dynamic BGP quarantine listeners.
|
||||||
|
// Spawned sessions use DynamicPeerProtocolPrefix ("evobgp_dyn_").
|
||||||
|
// Channel policy is always import none / export none (no announcements until approve).
|
||||||
|
func RenderDynamicBGPDiscovery(opts DynamicBGPDiscoveryOptions) (string, error) {
|
||||||
|
digits := opts.DynamicNameDigits
|
||||||
|
if digits <= 0 {
|
||||||
|
digits = 4
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
if len(opts.RangesV4) > 0 {
|
||||||
|
s, err := renderDynamicDiscoveryAF(DiscoveryProtocolV4, BGPTemplateNameV4, "ipv4", opts.RangesV4, opts.RequireExternal, digits)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
if len(opts.RangesV6) > 0 {
|
||||||
|
s, err := renderDynamicDiscoveryAF(DiscoveryProtocolV6, BGPTemplateNameV6, "ipv6", opts.RangesV6, opts.RequireExternal, digits)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDynamicDiscoveryAF(protoName, templateName, af string, ranges []string, external bool, digits int) (string, error) {
|
||||||
|
var cleaned []string
|
||||||
|
for _, r := range ranges {
|
||||||
|
r = strings.TrimSpace(r)
|
||||||
|
if r == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx, err := netip.ParsePrefix(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("birdfmt: invalid discovery range %q: %w", r, err)
|
||||||
|
}
|
||||||
|
if af == "ipv4" && !pfx.Addr().Is4() {
|
||||||
|
return "", fmt.Errorf("birdfmt: discovery range %q is not IPv4", r)
|
||||||
|
}
|
||||||
|
if af == "ipv6" && !pfx.Addr().Is6() {
|
||||||
|
return "", fmt.Errorf("birdfmt: discovery range %q is not IPv6", r)
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, pfx.Masked().String())
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "protocol bgp %s from %s {\n", protoName, templateName)
|
||||||
|
for _, cidr := range cleaned {
|
||||||
|
b.WriteString(" neighbor range ")
|
||||||
|
b.WriteString(cidr)
|
||||||
|
if external {
|
||||||
|
b.WriteString(" external")
|
||||||
|
}
|
||||||
|
b.WriteString(";\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " dynamic name \"%s\";\n", DynamicPeerProtocolPrefix)
|
||||||
|
fmt.Fprintf(&b, " dynamic name digits %d;\n", digits)
|
||||||
|
b.WriteString(" multihop;\n")
|
||||||
|
b.WriteString(" passive;\n")
|
||||||
|
fmt.Fprintf(&b, " %s {\n", af)
|
||||||
|
b.WriteString(" import none;\n")
|
||||||
|
b.WriteString(" export none;\n")
|
||||||
|
b.WriteString(" };\n")
|
||||||
|
b.WriteString("}\n")
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDiscoveryRanges splits a settings string (comma / newline / space separated) into CIDRs.
|
||||||
|
func ParseDiscoveryRanges(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw = strings.ReplaceAll(raw, ",", " ")
|
||||||
|
raw = strings.ReplaceAll(raw, "\n", " ")
|
||||||
|
raw = strings.ReplaceAll(raw, ";", " ")
|
||||||
|
fields := strings.Fields(raw)
|
||||||
|
out := make([]string, 0, len(fields))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, f := range fields {
|
||||||
|
f = strings.TrimSpace(f)
|
||||||
|
if f == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[f]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[f] = struct{}{}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package birdfmt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderDynamicBGPDiscovery_v4(t *testing.T) {
|
||||||
|
out, err := RenderDynamicBGPDiscovery(DynamicBGPDiscoveryOptions{
|
||||||
|
RangesV4: []string{"198.51.100.0/24", "203.0.113.0/24"},
|
||||||
|
RequireExternal: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"protocol bgp evobgp_discover_v4 from bgp_template",
|
||||||
|
"neighbor range 198.51.100.0/24 external;",
|
||||||
|
"neighbor range 203.0.113.0/24 external;",
|
||||||
|
`dynamic name "evobgp_dyn_";`,
|
||||||
|
"dynamic name digits 4;",
|
||||||
|
"multihop;",
|
||||||
|
"passive;",
|
||||||
|
"import none;",
|
||||||
|
"export none;",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDynamicBGPDiscovery_invalid(t *testing.T) {
|
||||||
|
_, err := RenderDynamicBGPDiscovery(DynamicBGPDiscoveryOptions{
|
||||||
|
RangesV4: []string{"not-a-cidr"},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDiscoveryRanges(t *testing.T) {
|
||||||
|
got := ParseDiscoveryRanges("198.51.100.0/24, 203.0.113.0/24\n198.51.100.0/24")
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
package birdfmt
|
package birdfmt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BGPSession is one BGP protocol block from `birdc show protocols all`.
|
// BGPSession is one BGP protocol block from `birdc show protocols all`.
|
||||||
type BGPSession struct {
|
type BGPSession struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Neighbor string `json:"neighbor,omitempty"`
|
Neighbor string `json:"neighbor,omitempty"`
|
||||||
State string `json:"state"`
|
NeighborAS int64 `json:"neighbor_as,omitempty"`
|
||||||
|
NeighborID string `json:"neighbor_id,omitempty"`
|
||||||
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseBGPSessions extracts BGP protocol name, state, and neighbor (if present) from birdc output.
|
// DynamicPeerProtocolPrefix is the BIRD protocol name prefix for discovery-spawned sessions.
|
||||||
|
const DynamicPeerProtocolPrefix = "evobgp_dyn_"
|
||||||
|
|
||||||
|
// IsDynamicDiscoverySession reports whether the protocol was spawned by the discovery listener.
|
||||||
|
func IsDynamicDiscoverySession(name string) bool {
|
||||||
|
return strings.HasPrefix(strings.TrimSpace(name), DynamicPeerProtocolPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBGPSessions extracts BGP protocol name, state, neighbor, Neighbor AS, and Neighbor ID from birdc output.
|
||||||
func ParseBGPSessions(output string) []BGPSession {
|
func ParseBGPSessions(output string) []BGPSession {
|
||||||
var out []BGPSession
|
var out []BGPSession
|
||||||
var cur *BGPSession
|
var cur *BGPSession
|
||||||
@@ -43,12 +54,41 @@ func ParseBGPSessions(output string) []BGPSession {
|
|||||||
if cur == nil {
|
if cur == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
|
parseBGPSessionDetailLine(cur, trim)
|
||||||
if idx := strings.Index(trim, prefix); idx >= 0 {
|
|
||||||
cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBGPSessionDetailLine(cur *BGPSession, trim string) {
|
||||||
|
for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
|
||||||
|
if idx := strings.Index(trim, prefix); idx >= 0 {
|
||||||
|
// Avoid matching "Neighbor AS:" / "Neighbor ID:" via bare "Neighbor:"
|
||||||
|
if prefix == "Neighbor:" {
|
||||||
|
rest := strings.TrimSpace(trim[idx+len(prefix):])
|
||||||
|
if strings.HasPrefix(strings.ToLower(rest), "as:") || strings.HasPrefix(strings.ToLower(rest), "id:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(trim), "neighbor as:") || strings.Contains(strings.ToLower(trim), "neighbor id:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"Neighbor AS:", "Neighbor As:", "Neighbor as:"} {
|
||||||
|
if idx := strings.Index(trim, prefix); idx >= 0 {
|
||||||
|
raw := strings.TrimSpace(trim[idx+len(prefix):])
|
||||||
|
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
cur.NeighborAS = n
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"Neighbor ID:", "Neighbor Id:", "Neighbor id:", "BGP Identifier:", "BGP identifier:"} {
|
||||||
|
if idx := strings.Index(trim, prefix); idx >= 0 {
|
||||||
|
cur.NeighborID = strings.TrimSpace(trim[idx+len(prefix):])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,17 +10,40 @@ device1 Device --- up 10:00:00
|
|||||||
evobgp_p_abc123 BGP master4 up 10:00:05 Established
|
evobgp_p_abc123 BGP master4 up 10:00:05 Established
|
||||||
Neighbor address: 198.51.100.2
|
Neighbor address: 198.51.100.2
|
||||||
Neighbor AS: 65001
|
Neighbor AS: 65001
|
||||||
|
Neighbor ID: 192.0.2.50
|
||||||
evobgp_p_def456 BGP master4 up 10:00:06 Active
|
evobgp_p_def456 BGP master4 up 10:00:06 Active
|
||||||
Neighbor address: 2001:db8::2
|
Neighbor address: 2001:db8::2
|
||||||
|
evobgp_dyn_0001 BGP master4 up 10:00:07 Established
|
||||||
|
Neighbor address: 203.0.113.10
|
||||||
|
Neighbor AS: 65099
|
||||||
|
Neighbor ID: 203.0.113.10
|
||||||
`
|
`
|
||||||
sessions := ParseBGPSessions(sample)
|
sessions := ParseBGPSessions(sample)
|
||||||
if len(sessions) != 2 {
|
if len(sessions) != 3 {
|
||||||
t.Fatalf("got %d sessions want 2", len(sessions))
|
t.Fatalf("got %d sessions want 3", len(sessions))
|
||||||
}
|
}
|
||||||
if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" {
|
if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" {
|
||||||
t.Fatalf("session0: %+v", sessions[0])
|
t.Fatalf("session0: %+v", sessions[0])
|
||||||
}
|
}
|
||||||
|
if sessions[0].NeighborAS != 65001 || sessions[0].NeighborID != "192.0.2.50" {
|
||||||
|
t.Fatalf("session0 ids: as=%d id=%q", sessions[0].NeighborAS, sessions[0].NeighborID)
|
||||||
|
}
|
||||||
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
|
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
|
||||||
t.Fatalf("session1: %+v", sessions[1])
|
t.Fatalf("session1: %+v", sessions[1])
|
||||||
}
|
}
|
||||||
|
if !IsDynamicDiscoverySession(sessions[2].Name) {
|
||||||
|
t.Fatalf("session2 should be dynamic: %+v", sessions[2])
|
||||||
|
}
|
||||||
|
if sessions[2].NeighborAS != 65099 || sessions[2].NeighborID != "203.0.113.10" {
|
||||||
|
t.Fatalf("session2 ids: %+v", sessions[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDynamicDiscoverySession(t *testing.T) {
|
||||||
|
if !IsDynamicDiscoverySession("evobgp_dyn_0001") {
|
||||||
|
t.Fatal("expected true")
|
||||||
|
}
|
||||||
|
if IsDynamicDiscoverySession("evobgp_p_abc") {
|
||||||
|
t.Fatal("expected false")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
|
|||||||
"bgp_ipv4_peer",
|
"bgp_ipv4_peer",
|
||||||
"bgp_ipv6_peer",
|
"bgp_ipv6_peer",
|
||||||
"domains_resolved",
|
"domains_resolved",
|
||||||
|
"dynamic_discovery",
|
||||||
"empty_static",
|
"empty_static",
|
||||||
"filter_export",
|
"filter_export",
|
||||||
"large_prefix_list",
|
"large_prefix_list",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# tags: dynamic, discovery, bgp
|
||||||
|
# Dynamic BGP discovery quarantine listener (neighbor range + import/export none).
|
||||||
|
|
||||||
|
router id 192.0.2.1;
|
||||||
|
|
||||||
|
protocol device {
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol direct {
|
||||||
|
ipv4;
|
||||||
|
ipv6;
|
||||||
|
}
|
||||||
|
|
||||||
|
filter evobgp_export_v4 {
|
||||||
|
if net ~ [ 203.0.113.0/24 ] then accept;
|
||||||
|
reject;
|
||||||
|
}
|
||||||
|
|
||||||
|
filter evobgp_export_v6 {
|
||||||
|
reject;
|
||||||
|
}
|
||||||
|
|
||||||
|
template bgp bgp_template {
|
||||||
|
local as 65001;
|
||||||
|
ipv4 {
|
||||||
|
import none;
|
||||||
|
export filter evobgp_export_v4;
|
||||||
|
};
|
||||||
|
hold time 90;
|
||||||
|
keepalive time 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
template bgp bgp_template_v6 {
|
||||||
|
local as 65001;
|
||||||
|
ipv6 {
|
||||||
|
import none;
|
||||||
|
export filter evobgp_export_v6;
|
||||||
|
};
|
||||||
|
hold time 90;
|
||||||
|
keepalive time 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol bgp evobgp_discover_v4 from bgp_template {
|
||||||
|
neighbor range 198.51.100.0/24 external;
|
||||||
|
dynamic name "evobgp_dyn_";
|
||||||
|
dynamic name digits 4;
|
||||||
|
multihop;
|
||||||
|
passive;
|
||||||
|
ipv4 {
|
||||||
|
import none;
|
||||||
|
export none;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -160,9 +160,6 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
|||||||
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
|
// 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.
|
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
||||||
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||||
if strings.TrimSpace(s.portalTenantID) == "" {
|
|
||||||
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false
|
|
||||||
}
|
|
||||||
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, jwt.ErrSignatureInvalid
|
return nil, jwt.ErrSignatureInvalid
|
||||||
@@ -190,12 +187,19 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
|||||||
if strings.TrimSpace(sub) == "" {
|
if strings.TrimSpace(sub) == "" {
|
||||||
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
|
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)
|
email, _ := claims["email"].(string)
|
||||||
perms := coerceStringSlice(claims["permissions"])
|
perms := coerceStringSlice(claims["permissions"])
|
||||||
isAdmin, _ := claims["is_admin"].(bool)
|
isAdmin, _ := claims["is_admin"].(bool)
|
||||||
return Auth{
|
return Auth{
|
||||||
Kind: AuthKindJWT,
|
Kind: AuthKindJWT,
|
||||||
TenantID: s.portalTenantID,
|
TenantID: tenantID,
|
||||||
UserID: strings.TrimSpace(sub),
|
UserID: strings.TrimSpace(sub),
|
||||||
Email: strings.TrimSpace(email),
|
Email: strings.TrimSpace(email),
|
||||||
Permissions: perms,
|
Permissions: perms,
|
||||||
@@ -204,6 +208,21 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
|||||||
}, 0, "", true
|
}, 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 {
|
func coerceStringSlice(v any) []string {
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case []string:
|
case []string:
|
||||||
|
|||||||
@@ -181,6 +181,83 @@ func TestAuthJWTMissingPermissionRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) {
|
||||||
|
srv, err := New(Options{
|
||||||
|
SeedDemo: true,
|
||||||
|
BundleSeedHex: testBundleSeed,
|
||||||
|
JWTSecret: testJWTSecret,
|
||||||
|
AuthIssuer: testIssuer,
|
||||||
|
AuthPortalURL: "https://portal.test.local",
|
||||||
|
AuthRequired: true,
|
||||||
|
// No PortalTenantID — must come from JWT claim.
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
token := signTestJWT(t, jwt.MapClaims{
|
||||||
|
"iss": testIssuer,
|
||||||
|
"sub": "user-1",
|
||||||
|
"apps": []string{"bgp"},
|
||||||
|
"permissions": []string{"bgp:modules:read"},
|
||||||
|
"bgp_tenant_id": tenant,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthJWTRejectedWhenTenantMissing(t *testing.T) {
|
||||||
|
srv, err := New(Options{
|
||||||
|
SeedDemo: true,
|
||||||
|
BundleSeedHex: testBundleSeed,
|
||||||
|
JWTSecret: testJWTSecret,
|
||||||
|
AuthIssuer: testIssuer,
|
||||||
|
AuthPortalURL: "https://portal.test.local",
|
||||||
|
AuthRequired: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
token := signTestJWT(t, jwt.MapClaims{
|
||||||
|
"iss": testIssuer,
|
||||||
|
"sub": "user-1",
|
||||||
|
"apps": []string{"bgp"},
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status=%d want 503", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthConfigPublic(t *testing.T) {
|
func TestAuthConfigPublic(t *testing.T) {
|
||||||
srv, _ := newJWTTestServer(t)
|
srv, _ := newJWTTestServer(t)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/birdfmt"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) registerPeerDiscoveryRoutes(m *http.ServeMux) {
|
||||||
|
m.HandleFunc("GET /peers/discovered", s.handleListPeerDiscoveries)
|
||||||
|
m.HandleFunc("POST /peers/discovered/{id}/approve", s.handleApprovePeerDiscovery)
|
||||||
|
m.HandleFunc("POST /peers/discovered/{id}/reject", s.handleRejectPeerDiscovery)
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerDiscoveryJSON(d *store.BGPPeerDiscovery) map[string]any {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := map[string]any{
|
||||||
|
"id": d.ID,
|
||||||
|
"neighbor_id": d.NeighborID,
|
||||||
|
"neighbor": d.Neighbor,
|
||||||
|
"remote_asn": d.RemoteASN,
|
||||||
|
"protocol_name": d.ProtocolName,
|
||||||
|
"session_state": d.SessionState,
|
||||||
|
"status": d.Status,
|
||||||
|
"first_seen_at": d.FirstSeenAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"last_seen_at": d.LastSeenAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if d.SpeakerID != "" {
|
||||||
|
m["speaker_id"] = d.SpeakerID
|
||||||
|
} else {
|
||||||
|
m["speaker_id"] = nil
|
||||||
|
}
|
||||||
|
if d.ApprovedPeerID != "" {
|
||||||
|
m["approved_peer_id"] = d.ApprovedPeerID
|
||||||
|
} else {
|
||||||
|
m["approved_peer_id"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPeerDiscoveriesFromLive upserts pending discoveries from dynamic BGP sessions.
|
||||||
|
func (s *Server) syncPeerDiscoveriesFromLive(tenantID string, views []speakerBGPLive) {
|
||||||
|
if s.store == nil || tenantID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
knownNeighbors := map[string]struct{}{}
|
||||||
|
for _, p := range s.store.ListPeers(tenantID) {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, ok := store.NormalizePeerNeighborString(p.Neighbor); ok {
|
||||||
|
knownNeighbors[n] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, v := range views {
|
||||||
|
for _, sess := range v.Sessions {
|
||||||
|
if !birdfmt.IsDynamicDiscoverySession(sess.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
neighbor, ok := store.NormalizePeerNeighborString(sess.Neighbor)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, known := knownNeighbors[neighbor]; known {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = s.store.UpsertPeerDiscovery(tenantID, &store.PeerDiscoveryUpsert{
|
||||||
|
SpeakerID: v.SpeakerID,
|
||||||
|
NeighborID: strings.TrimSpace(sess.NeighborID),
|
||||||
|
Neighbor: neighbor,
|
||||||
|
RemoteASN: sess.NeighborAS,
|
||||||
|
ProtocolName: sess.Name,
|
||||||
|
SessionState: sess.State,
|
||||||
|
SeenAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListPeerDiscoveries(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:network:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||||
|
if status == "" {
|
||||||
|
status = store.PeerDiscoveryPending
|
||||||
|
}
|
||||||
|
// Refresh live discovery when listing pending.
|
||||||
|
if status == store.PeerDiscoveryPending || status == "all" {
|
||||||
|
views := s.collectSpeakerBGPLive(r.Context(), a.TenantID, true)
|
||||||
|
s.syncPeerDiscoveriesFromLive(a.TenantID, views)
|
||||||
|
}
|
||||||
|
listStatus := status
|
||||||
|
if status == "all" {
|
||||||
|
listStatus = ""
|
||||||
|
}
|
||||||
|
items, err := s.store.ListPeerDiscoveries(a.TenantID, listStatus)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(items))
|
||||||
|
for _, d := range items {
|
||||||
|
out = append(out, peerDiscoveryJSON(d))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleApprovePeerDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BGPSpeakerID *string `json:"bgp_speaker_id"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
peer, disc, err := s.store.ApprovePeerDiscovery(a.TenantID, r.PathValue("id"), &store.PeerDiscoveryApproveInput{
|
||||||
|
Name: body.Name,
|
||||||
|
SpeakerID: body.BGPSpeakerID,
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.enqueuePeerReconcile(a.TenantID, "peer_discovery_approve")
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.peer.discovery.approve", "Approved discovered peer "+peer.Neighbor, peer.ID, map[string]any{
|
||||||
|
"peer_id": peer.ID,
|
||||||
|
"discovery_id": disc.ID,
|
||||||
|
"neighbor": peer.Neighbor,
|
||||||
|
"neighbor_id": disc.NeighborID,
|
||||||
|
"remote_asn": peer.RemoteASN,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"peer": peerJSON(peer),
|
||||||
|
"discovery": peerDiscoveryJSON(disc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRejectPeerDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disc, err := s.store.RejectPeerDiscovery(a.TenantID, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.peer.discovery.reject", "Rejected discovered peer "+disc.Neighbor, disc.ID, map[string]any{
|
||||||
|
"discovery_id": disc.ID,
|
||||||
|
"neighbor": disc.Neighbor,
|
||||||
|
"neighbor_id": disc.NeighborID,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, peerDiscoveryJSON(disc))
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPeerDiscoveryApproveReject(t *testing.T) {
|
||||||
|
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
tenant, _, _, _, speaker := srv.Store().DemoIDs()
|
||||||
|
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|operator")
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
disc, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
|
||||||
|
SpeakerID: speaker,
|
||||||
|
NeighborID: "203.0.113.10",
|
||||||
|
Neighbor: "203.0.113.10",
|
||||||
|
RemoteASN: 65099,
|
||||||
|
ProtocolName: "evobgp_dyn_0001",
|
||||||
|
SessionState: "Established",
|
||||||
|
SeenAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listReq := httptest.NewRequest(http.MethodGet, "/v1/peers/discovered?status=pending", nil)
|
||||||
|
listReq.Header.Set("Authorization", "Bearer edkey")
|
||||||
|
listRec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(listRec, listReq)
|
||||||
|
if listRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list status %d body %s", listRec.Code, listRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
approveBody := `{"name":"client-a"}`
|
||||||
|
approveReq := httptest.NewRequest(http.MethodPost, "/v1/peers/discovered/"+disc.ID+"/approve", strings.NewReader(approveBody))
|
||||||
|
approveReq.Header.Set("Authorization", "Bearer edkey")
|
||||||
|
approveReq.Header.Set("Content-Type", "application/json")
|
||||||
|
approveRec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(approveRec, approveReq)
|
||||||
|
if approveRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve status %d body %s", approveRec.Code, approveRec.Body.String())
|
||||||
|
}
|
||||||
|
var approveOut struct {
|
||||||
|
Peer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Neighbor string `json:"neighbor"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"peer"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(approveRec.Body.Bytes(), &approveOut); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if approveOut.Peer.Neighbor != "203.0.113.10" || approveOut.Peer.Name != "client-a" {
|
||||||
|
t.Fatalf("unexpected peer: %+v", approveOut.Peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
peers := srv.Store().ListPeers(tenant)
|
||||||
|
found := false
|
||||||
|
for _, p := range peers {
|
||||||
|
if p.ID == approveOut.Peer.ID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("approved peer not in ListPeers")
|
||||||
|
}
|
||||||
|
|
||||||
|
disc2, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
|
||||||
|
SpeakerID: speaker,
|
||||||
|
NeighborID: "198.51.100.99",
|
||||||
|
Neighbor: "198.51.100.99",
|
||||||
|
RemoteASN: 65100,
|
||||||
|
ProtocolName: "evobgp_dyn_0002",
|
||||||
|
SessionState: "Active",
|
||||||
|
SeenAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rejReq := httptest.NewRequest(http.MethodPost, "/v1/peers/discovered/"+disc2.ID+"/reject", nil)
|
||||||
|
rejReq.Header.Set("Authorization", "Bearer edkey")
|
||||||
|
rejRec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rejRec, rejReq)
|
||||||
|
if rejRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reject status %d body %s", rejRec.Code, rejRec.Body.String())
|
||||||
|
}
|
||||||
|
again, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
|
||||||
|
NeighborID: "198.51.100.99",
|
||||||
|
Neighbor: "198.51.100.99",
|
||||||
|
RemoteASN: 65100,
|
||||||
|
ProtocolName: "evobgp_dyn_0002",
|
||||||
|
SessionState: "Established",
|
||||||
|
SeenAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again.Status != store.PeerDiscoveryRejected {
|
||||||
|
t.Fatalf("expected rejected, got %s", again.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,9 +36,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||||
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
|
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
|
||||||
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
|
// Firewall subsystem moved to the standalone EvoFirewall service; see docs/firewall.md.
|
||||||
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
|
// Registered on the public mux so it wins over the "/v1/" subtree below regardless of auth.
|
||||||
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
|
s.mux.HandleFunc("/v1/firewall/", s.handleFirewallGone)
|
||||||
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
|
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
|
||||||
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
return s.withCORS(observability.HTTPMiddleware(s.mux))
|
||||||
}
|
}
|
||||||
@@ -83,11 +83,11 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
|
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
|
||||||
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
|
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
|
||||||
s.registerCRUDRoutes(m)
|
s.registerCRUDRoutes(m)
|
||||||
|
s.registerAuditRoutes(m)
|
||||||
s.registerPostgresMonitoringRoutes(m)
|
s.registerPostgresMonitoringRoutes(m)
|
||||||
s.registerPostgresMaintenanceRoutes(m)
|
s.registerPostgresMaintenanceRoutes(m)
|
||||||
s.registerMaintenanceRoutes(m)
|
s.registerMaintenanceRoutes(m)
|
||||||
s.registerRuntimeLogsRoutes(m)
|
s.registerRuntimeLogsRoutes(m)
|
||||||
s.registerFirewallRoutes(m)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -299,6 +299,9 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
|
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
|
||||||
|
if fresh {
|
||||||
|
s.syncPeerDiscoveriesFromLive(a.TenantID, liveViews)
|
||||||
|
}
|
||||||
items := make([]map[string]any, 0, len(page))
|
items := make([]map[string]any, 0, len(page))
|
||||||
for _, p := range page {
|
for _, p := range page {
|
||||||
row := peerJSON(p)
|
row := peerJSON(p)
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
out := apiKeyJSON(&created.APIKey)
|
out := apiKeyJSON(&created.APIKey)
|
||||||
out["token"] = created.Token
|
out["token"] = created.Token
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+created.Name, created.ID, map[string]any{"api_key_id": created.ID, "role": created.Role})
|
||||||
writeJSON(w, http.StatusCreated, out)
|
writeJSON(w, http.StatusCreated, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.api_key.update", "Updated API key "+k.Name, k.ID, map[string]any{"api_key_id": k.ID, "role": k.Role})
|
||||||
writeJSON(w, http.StatusOK, apiKeyJSON(k))
|
writeJSON(w, http.StatusOK, apiKeyJSON(k))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +197,8 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
|
keyID := r.PathValue("id")
|
||||||
|
if err := s.store.RevokeAPIKey(a.TenantID, keyID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -203,6 +206,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.api_key.revoke", "Revoked API key", keyID, map[string]any{"api_key_id": keyID})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,5 +226,6 @@ func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
out := apiKeyJSON(&rotated.APIKey)
|
out := apiKeyJSON(&rotated.APIKey)
|
||||||
out["token"] = rotated.Token
|
out["token"] = rotated.Token
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.api_key.rotate", "Rotated API key "+rotated.Name, rotated.ID, map[string]any{"api_key_id": rotated.ID})
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/audit"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) registerAuditRoutes(m *http.ServeMux) {
|
||||||
|
m.HandleFunc("GET /audit", s.handleListAudit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cursor := r.URL.Query().Get("cursor")
|
||||||
|
limit := parseLimitQuery(r, 20, 200)
|
||||||
|
filter := store.AuditListFilter{
|
||||||
|
Action: strings.TrimSpace(r.URL.Query().Get("action")),
|
||||||
|
Severity: strings.TrimSpace(r.URL.Query().Get("severity")),
|
||||||
|
}
|
||||||
|
if filter.Severity != "" && !store.ValidAuditSeverity(filter.Severity) {
|
||||||
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid severity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, next, hasMore, err := s.store.ListAudit(a.TenantID, cursor, limit, filter)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, "audit_list", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(items))
|
||||||
|
for _, row := range items {
|
||||||
|
out = append(out, auditEntryJSON(row))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditEntryJSON(row *store.AuditEntry) map[string]any {
|
||||||
|
if row == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
m := map[string]any{
|
||||||
|
"id": row.ID,
|
||||||
|
"tenant_id": row.TenantID,
|
||||||
|
"event_id": row.EventID,
|
||||||
|
"source_app": row.SourceApp,
|
||||||
|
"action": row.Action,
|
||||||
|
"severity": row.Severity,
|
||||||
|
"actor_user_id": strPtrOrNull(row.ActorUserID),
|
||||||
|
"actor_email": strPtrOrNull(row.ActorEmail),
|
||||||
|
"actor_name": strPtrOrNull(row.ActorName),
|
||||||
|
"actor_api_key_prefix": strPtrOrNull(row.ActorAPIKeyPrefix),
|
||||||
|
"target_type": strPtrOrNull(row.TargetType),
|
||||||
|
"target_id": strPtrOrNull(row.TargetID),
|
||||||
|
"summary": row.Summary,
|
||||||
|
"details": row.Details,
|
||||||
|
"ip": strPtrOrNull(row.IP),
|
||||||
|
"created_at": row.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"portal_pushed_at": nil,
|
||||||
|
}
|
||||||
|
if row.PortalPushedAt != nil {
|
||||||
|
m["portal_pushed_at"] = row.PortalPushedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
if m["details"] == nil {
|
||||||
|
m["details"] = nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) recordCRUDAudit(r *http.Request, a Auth, action, summary, targetID string, details map[string]any) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in := store.AuditAppendInput{
|
||||||
|
TenantID: a.TenantID,
|
||||||
|
Action: action,
|
||||||
|
Severity: store.AuditSeverityInfo,
|
||||||
|
TargetType: store.AuditTargetAppResource,
|
||||||
|
TargetID: targetID,
|
||||||
|
Summary: summary,
|
||||||
|
Details: details,
|
||||||
|
IP: clientIP(r),
|
||||||
|
}
|
||||||
|
fillAuditActor(&in, a)
|
||||||
|
entry, err := s.store.AppendAudit(in)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("httpapi: audit append action=%s: %v", action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pushAuditToPortal(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillAuditActor(in *store.AuditAppendInput, a Auth) {
|
||||||
|
if in == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.Kind == AuthKindJWT {
|
||||||
|
in.ActorUserID = strings.TrimSpace(a.UserID)
|
||||||
|
in.ActorEmail = strings.TrimSpace(a.Email)
|
||||||
|
if in.ActorEmail != "" {
|
||||||
|
in.ActorName = in.ActorEmail
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefix := actorPrefix(a)
|
||||||
|
in.ActorAPIKeyPrefix = prefix
|
||||||
|
if prefix != "" {
|
||||||
|
in.ActorName = "apikey:" + prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pushAuditToPortal(entry *store.AuditEntry) {
|
||||||
|
if s == nil || s.auditPusher == nil || entry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pusher := s.auditPusher
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := pusher.PushEvent(ctx, entry); err != nil {
|
||||||
|
log.Printf("httpapi: audit portal push event_id=%s: %v", entry.EventID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
|
||||||
|
return xrip
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(r.RemoteAddr)
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// initAuditPusher wires portal push when URL and secret are configured.
|
||||||
|
func (s *Server) initAuditPusher(portalURL, ingestSecret string) {
|
||||||
|
base := strings.TrimSpace(portalURL)
|
||||||
|
secret := strings.TrimSpace(ingestSecret)
|
||||||
|
if base == "" || secret == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.auditPusher = &audit.PortalPusher{
|
||||||
|
BaseURL: base,
|
||||||
|
Secret: secret,
|
||||||
|
MarkPushed: func(id string) error {
|
||||||
|
if s.store == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.store.MarkAuditPortalPushed(id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleListAudit(t *testing.T) {
|
||||||
|
mem := store.NewMemory()
|
||||||
|
mem.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := mem.DemoIDs()
|
||||||
|
|
||||||
|
srv, err := New(Options{SeedDemo: false, InsecureDev: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.store = mem
|
||||||
|
|
||||||
|
_, err = mem.AppendAudit(store.AuditAppendInput{
|
||||||
|
TenantID: tenant,
|
||||||
|
Action: "bgp.module.create",
|
||||||
|
Summary: "Created module demo",
|
||||||
|
TargetID: "mod-x",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/audit", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer dev")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(body.Items) != 1 {
|
||||||
|
t.Fatalf("items=%d", len(body.Items))
|
||||||
|
}
|
||||||
|
if body.Items[0]["action"] != "bgp.module.create" {
|
||||||
|
t.Fatalf("action=%v", body.Items[0]["action"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListCommunityPrefixesByIDAndLabel(t *testing.T) {
|
||||||
|
srv, err := New(Options{
|
||||||
|
InsecureDev: true,
|
||||||
|
SeedDemo: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
|
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
client := ts.Client()
|
||||||
|
base := ts.URL
|
||||||
|
|
||||||
|
reqList, _ := http.NewRequest(http.MethodGet, base+"/v1/communities?limit=10", nil)
|
||||||
|
reqList.Header.Set("Authorization", "Bearer vwkey")
|
||||||
|
respList, err := client.Do(reqList)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = respList.Body.Close() }()
|
||||||
|
if respList.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(respList.Body)
|
||||||
|
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
|
||||||
|
}
|
||||||
|
var listBody struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Community string `json:"community"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(respList.Body).Decode(&listBody); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(listBody.Items) == 0 {
|
||||||
|
t.Fatal("expected seeded community")
|
||||||
|
}
|
||||||
|
comm := listBody.Items[0]
|
||||||
|
|
||||||
|
assertPrefixesOK := func(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer vwkey")
|
||||||
|
resp, err := 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("%s status %d: %s", path, resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
Prefixes []string `json:"prefixes"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body.Items == nil {
|
||||||
|
t.Fatalf("%s: expected items array (got nil)", path)
|
||||||
|
}
|
||||||
|
if body.Prefixes == nil {
|
||||||
|
t.Fatalf("%s: expected prefixes array (got nil)", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUID id
|
||||||
|
assertPrefixesOK(t, "/v1/communities/"+comm.ID+"/prefixes?limit=100")
|
||||||
|
// Community string (legacy / autocomplete label without title)
|
||||||
|
assertPrefixesOK(t, "/v1/communities/"+url.PathEscape(comm.Community)+"/prefixes?limit=100")
|
||||||
|
if comm.Title != "" {
|
||||||
|
// Full Base UI {value,label} display string
|
||||||
|
label := comm.Community + " · " + comm.Title
|
||||||
|
assertPrefixesOK(t, "/v1/communities/"+url.PathEscape(label)+"/prefixes?limit=100")
|
||||||
|
}
|
||||||
|
|
||||||
|
req404, _ := http.NewRequest(http.MethodGet, base+"/v1/communities/missing-community/prefixes", nil)
|
||||||
|
req404.Header.Set("Authorization", "Bearer vwkey")
|
||||||
|
resp404, err := client.Do(req404)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp404.Body.Close() }()
|
||||||
|
if resp404.StatusCode != http.StatusNotFound {
|
||||||
|
b, _ := io.ReadAll(resp404.Body)
|
||||||
|
t.Fatalf("expected 404, got %d: %s", resp404.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -56,6 +57,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
|||||||
|
|
||||||
m.HandleFunc("GET /communities", s.handleListComm)
|
m.HandleFunc("GET /communities", s.handleListComm)
|
||||||
m.HandleFunc("POST /communities", s.handlePostComm)
|
m.HandleFunc("POST /communities", s.handlePostComm)
|
||||||
|
m.HandleFunc("GET /communities/{id}/prefixes", s.handleListCommPrefixes)
|
||||||
m.HandleFunc("GET /communities/{id}", s.handleGetComm)
|
m.HandleFunc("GET /communities/{id}", s.handleGetComm)
|
||||||
m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm)
|
m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm)
|
||||||
m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm)
|
m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm)
|
||||||
@@ -64,6 +66,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
|||||||
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
|
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
|
||||||
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
|
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
|
||||||
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
|
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
|
||||||
|
s.registerPeerDiscoveryRoutes(m)
|
||||||
|
|
||||||
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
||||||
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
||||||
@@ -113,6 +116,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.module.create", "Created module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "type": mod.Type, "name": mod.Name})
|
||||||
writeJSON(w, http.StatusCreated, moduleJSON(mod))
|
writeJSON(w, http.StatusCreated, moduleJSON(mod))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +178,7 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.module.update", "Updated module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "name": mod.Name})
|
||||||
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +198,7 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.module.delete", "Deleted module", moduleID, map[string]any{"module_id": moduleID})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +233,9 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
|
|||||||
case "23505":
|
case "23505":
|
||||||
writeProblem(w, http.StatusConflict, "Conflict", "resource already exists")
|
writeProblem(w, http.StatusConflict, "Conflict", "resource already exists")
|
||||||
return true
|
return true
|
||||||
|
case "22P02":
|
||||||
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid id format")
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -369,6 +378,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.cdn_source.create", "Created CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID, "url": x.URL})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
|
||||||
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
|
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
|
||||||
}
|
}
|
||||||
@@ -399,6 +409,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.cdn_source.update", "Updated CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
|
||||||
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
|
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
|
||||||
}
|
}
|
||||||
@@ -409,10 +420,12 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mid := r.PathValue("module_id")
|
mid := r.PathValue("module_id")
|
||||||
if err := s.store.DeleteCDNSource(a.TenantID, mid, r.PathValue("source_id")); err != nil {
|
sourceID := r.PathValue("source_id")
|
||||||
|
if err := s.store.DeleteCDNSource(a.TenantID, mid, sourceID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.cdn_source.delete", "Deleted CDN source", sourceID, map[string]any{"module_id": mid, "source_id": sourceID})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -471,6 +484,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.as_entry.create", "Created AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
|
||||||
writeJSON(w, http.StatusCreated, asEntryJSON(x))
|
writeJSON(w, http.StatusCreated, asEntryJSON(x))
|
||||||
}
|
}
|
||||||
@@ -491,6 +505,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.as_entry.update", "Updated AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
|
||||||
writeJSON(w, http.StatusOK, asEntryJSON(x))
|
writeJSON(w, http.StatusOK, asEntryJSON(x))
|
||||||
}
|
}
|
||||||
@@ -501,10 +516,12 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mid := r.PathValue("module_id")
|
mid := r.PathValue("module_id")
|
||||||
if err := s.store.DeleteASEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
entryID := r.PathValue("entry_id")
|
||||||
|
if err := s.store.DeleteASEntry(a.TenantID, mid, entryID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.as_entry.delete", "Deleted AS entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -548,6 +565,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.domain_entry.create", "Created domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
|
||||||
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
|
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
|
||||||
}
|
}
|
||||||
@@ -568,6 +586,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.domain_entry.update", "Updated domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
|
||||||
writeJSON(w, http.StatusOK, domainEntryJSON(x))
|
writeJSON(w, http.StatusOK, domainEntryJSON(x))
|
||||||
}
|
}
|
||||||
@@ -578,10 +597,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mid := r.PathValue("module_id")
|
mid := r.PathValue("module_id")
|
||||||
if err := s.store.DeleteDomainEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
entryID := r.PathValue("entry_id")
|
||||||
|
if err := s.store.DeleteDomainEntry(a.TenantID, mid, entryID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.domain_entry.delete", "Deleted domain entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -625,6 +646,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.ip_range.create", "Created IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create")
|
||||||
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
|
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
|
||||||
}
|
}
|
||||||
@@ -645,6 +667,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.ip_range.update", "Updated IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch")
|
||||||
writeJSON(w, http.StatusOK, ipRangeJSON(x))
|
writeJSON(w, http.StatusOK, ipRangeJSON(x))
|
||||||
}
|
}
|
||||||
@@ -655,10 +678,12 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mid := r.PathValue("module_id")
|
mid := r.PathValue("module_id")
|
||||||
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
|
entryID := r.PathValue("entry_id")
|
||||||
|
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, entryID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.ip_range.delete", "Deleted IP range entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
|
||||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete")
|
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -850,6 +875,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.doh_profile.create", "Created DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
|
||||||
writeJSON(w, http.StatusCreated, dohJSON(x))
|
writeJSON(w, http.StatusCreated, dohJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,6 +894,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.doh_profile.update", "Updated DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
|
||||||
writeJSON(w, http.StatusOK, dohJSON(x))
|
writeJSON(w, http.StatusOK, dohJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,10 +903,12 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
|
profileID := r.PathValue("id")
|
||||||
|
if err := s.store.DeleteDohProfile(a.TenantID, profileID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.doh_profile.delete", "Deleted DoH profile", profileID, map[string]any{"profile_id": profileID})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,6 +950,39 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, commJSON(x))
|
writeJSON(w, http.StatusOK, commJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListCommPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
cursor := r.URL.Query().Get("cursor")
|
||||||
|
rows, next, more, err := s.store.ListCommunityPrefixes(a.TenantID, r.PathValue("id"), cursor, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(rows))
|
||||||
|
prefixes := make([]string, 0, len(rows))
|
||||||
|
for _, pr := range rows {
|
||||||
|
m := map[string]any{"prefix": pr.Prefix}
|
||||||
|
if pr.Source != "" {
|
||||||
|
m["source"] = pr.Source
|
||||||
|
}
|
||||||
|
items = append(items, m)
|
||||||
|
prefixes = append(prefixes, pr.Prefix)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"prefixes": prefixes,
|
||||||
|
"next_cursor": strPtrOrNull(next),
|
||||||
|
"has_more": more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := authFromContext(r.Context())
|
a, ok := authFromContext(r.Context())
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||||
@@ -936,6 +998,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.community.create", "Created community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
|
||||||
writeJSON(w, http.StatusCreated, commJSON(x))
|
writeJSON(w, http.StatusCreated, commJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +1017,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.community.update", "Updated community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
|
||||||
writeJSON(w, http.StatusOK, commJSON(x))
|
writeJSON(w, http.StatusOK, commJSON(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,10 +1026,12 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
|
commID := r.PathValue("id")
|
||||||
|
if err := s.store.DeleteCommunity(a.TenantID, commID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.community.delete", "Deleted community", commID, map[string]any{"community_id": commID})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,6 +1054,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.peer.create", "Created BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
|
||||||
s.enqueuePeerReconcile(a.TenantID, "peer_create")
|
s.enqueuePeerReconcile(a.TenantID, "peer_create")
|
||||||
writeJSON(w, http.StatusCreated, peerJSON(x))
|
writeJSON(w, http.StatusCreated, peerJSON(x))
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1098,7 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.peer.update", "Updated BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
|
||||||
s.enqueuePeerReconcile(a.TenantID, "peer_patch")
|
s.enqueuePeerReconcile(a.TenantID, "peer_patch")
|
||||||
writeJSON(w, http.StatusOK, peerJSON(x))
|
writeJSON(w, http.StatusOK, peerJSON(x))
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1119,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.peer.delete", "Deleted BGP peer", peerID, map[string]any{"peer_id": peerID})
|
||||||
s.enqueuePeerReconcile(a.TenantID, "peer_delete")
|
s.enqueuePeerReconcile(a.TenantID, "peer_delete")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -1074,6 +1143,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.speaker.create", "Created speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
|
||||||
resp := speakerJSONFromStore(s.store, x)
|
resp := speakerJSONFromStore(s.store, x)
|
||||||
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
||||||
resp["agent_secret"] = meta.AgentSecret
|
resp["agent_secret"] = meta.AgentSecret
|
||||||
@@ -1109,6 +1179,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.speaker.update", "Updated speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
|
||||||
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,10 +1188,12 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
|
speakerID := r.PathValue("speaker_id")
|
||||||
|
if err := s.store.DeleteSpeaker(a.TenantID, speakerID); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.speaker.delete", "Deleted speaker", speakerID, map[string]any{"speaker_id": speakerID})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,9 +1260,39 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if peerDiscoverySettingsChanged(body) {
|
||||||
|
s.enqueuePeerReconcile(a.TenantID, "peer_discovery_settings")
|
||||||
|
}
|
||||||
|
s.recordCRUDAudit(r, a, "bgp.settings.update", "Updated tenant settings", a.TenantID, map[string]any{"keys": settingsAuditKeys(body)})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func peerDiscoverySettingsChanged(body map[string]any) bool {
|
||||||
|
for _, k := range []string{
|
||||||
|
"peer_discovery_enabled",
|
||||||
|
"peer_discovery_ranges_v4",
|
||||||
|
"peer_discovery_ranges_v6",
|
||||||
|
"peer_discovery_require_external",
|
||||||
|
} {
|
||||||
|
if _, ok := body[k]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsAuditKeys(body map[string]any) []string {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(body))
|
||||||
|
for k := range body {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
func parseRevisionRetentionMinutes(v any) (int, bool) {
|
func parseRevisionRetentionMinutes(v any) (int, bool) {
|
||||||
const minMinutes = 15
|
const minMinutes = 15
|
||||||
const maxMinutes = 30 * 24 * 60
|
const maxMinutes = 30 * 24 * 60
|
||||||
|
|||||||
@@ -1,709 +1,16 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import "net/http"
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"evobgp/internal/authkey"
|
// handleFirewallGone responds 410 Gone for every legacy /v1/firewall/* endpoint
|
||||||
"evobgp/internal/firewall"
|
// (enroll, install.sh, sync-script, clients, rules, blocklist, apply-report,
|
||||||
"evobgp/internal/firewallscripts"
|
// heartbeat, install-context). The firewall subsystem (client enrollment,
|
||||||
"evobgp/internal/store"
|
// block/accept rules, blocklist distribution) has moved to the standalone
|
||||||
)
|
// EvoFirewall service; see docs/firewall.md.
|
||||||
|
//
|
||||||
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
// Existing firewall_client / firewall_rule tables and store code (memory_firewall.go,
|
||||||
m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext)
|
// postgres_firewall.go, firewall_types.go) are intentionally left in place — only the
|
||||||
m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients)
|
// HTTP surface is decommissioned here.
|
||||||
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
|
func (s *Server) handleFirewallGone(w http.ResponseWriter, r *http.Request) {
|
||||||
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview)
|
writeProblem(w, http.StatusGone, "Gone", "firewall feature moved to EvoFirewall — see docs/firewall.md")
|
||||||
m.HandleFunc("PATCH /firewall/clients/{id}", s.handlePatchFirewallClient)
|
|
||||||
m.HandleFunc("POST /firewall/clients/{id}/approve", s.handleApproveFirewallClient)
|
|
||||||
m.HandleFunc("POST /firewall/clients/{id}/revoke", s.handleRevokeFirewallClient)
|
|
||||||
m.HandleFunc("DELETE /firewall/clients/{id}", s.handleDeleteFirewallClient)
|
|
||||||
|
|
||||||
m.HandleFunc("GET /firewall/rules", s.handleListFirewallRules)
|
|
||||||
m.HandleFunc("POST /firewall/rules", s.handleCreateFirewallRule)
|
|
||||||
m.HandleFunc("PATCH /firewall/rules/{id}", s.handlePatchFirewallRule)
|
|
||||||
m.HandleFunc("DELETE /firewall/rules/{id}", s.handleDeleteFirewallRule)
|
|
||||||
m.HandleFunc("POST /firewall/rules:reorder", s.handleReorderFirewallRules)
|
|
||||||
|
|
||||||
m.HandleFunc("GET /firewall/blocklist", s.handleFirewallBlocklist)
|
|
||||||
m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport)
|
|
||||||
m.HandleFunc("POST /firewall/heartbeat", s.handleFirewallHeartbeat)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seed := strings.TrimSpace(s.bundleSeedHex)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"bundle_seed": seed,
|
|
||||||
"bundle_seed_configured": seed != "",
|
|
||||||
"suggested_cp_url": publicHTTPSBaseURL(r),
|
|
||||||
"install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links.
|
|
||||||
func publicHTTPSBaseURL(r *http.Request) string {
|
|
||||||
host := strings.TrimSpace(r.Host)
|
|
||||||
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" {
|
|
||||||
host = strings.TrimSpace(strings.Split(xf, ",")[0])
|
|
||||||
}
|
|
||||||
if host == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "https://" + host
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestBaseURL(r *http.Request) string {
|
|
||||||
return publicHTTPSBaseURL(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seed := strings.TrimSpace(r.Header.Get("X-EvoBGP-Seed"))
|
|
||||||
if seed == "" || s.bundleSeedHex == "" || !strings.EqualFold(seed, s.bundleSeedHex) {
|
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "invalid or missing X-EvoBGP-Seed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
ClientToken string `json:"client_token"`
|
|
||||||
ClientVersion string `json:"client_version"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(body.Name)
|
|
||||||
tok := strings.TrimSpace(body.ClientToken)
|
|
||||||
if name == "" || tok == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "name and client_token are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(tok, "evobgp_fw_") {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_token must use evobgp_fw_ prefix")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tenantID, err := s.firewallEnrollTenantID()
|
|
||||||
if err != nil {
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash := authkey.HashToken(tok)
|
|
||||||
prefix := tok
|
|
||||||
if len(prefix) > 12 {
|
|
||||||
prefix = prefix[:12]
|
|
||||||
}
|
|
||||||
client, err := s.store.CreateFirewallClient(tenantID, &store.FirewallClientCreate{
|
|
||||||
Name: name,
|
|
||||||
Hostname: strings.TrimSpace(body.Hostname),
|
|
||||||
TokenPrefix: prefix,
|
|
||||||
TokenHash: hash,
|
|
||||||
ClientVersion: strings.TrimSpace(body.ClientVersion),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrInvalidInput) {
|
|
||||||
writeProblem(w, http.StatusConflict, "Conflict", "client token already enrolled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeStoreErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{
|
|
||||||
"client_id": client.ID,
|
|
||||||
"status": client.Status,
|
|
||||||
"message": "pending operator approval in EvoBGP UI",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) firewallEnrollTenantID() (string, error) {
|
|
||||||
tid, _, _, _, _ := s.store.DemoIDs()
|
|
||||||
if tid != "" {
|
|
||||||
return tid, nil
|
|
||||||
}
|
|
||||||
ids, err := s.store.ListTenantIDs()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return "", errors.New("httpapi: no tenant for firewall enroll")
|
|
||||||
}
|
|
||||||
return ids[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallInstallScript(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.serveFirewallScript(w, "install.sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallSyncScript(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.serveFirewallScript(w, "evobgp-firewall.sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) serveFirewallScript(w http.ResponseWriter, name string) {
|
|
||||||
b, err := readFirewallScript(name)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "script not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFirewallScript(name string) ([]byte, error) {
|
|
||||||
if b, err := firewallscripts.FS.ReadFile(name); err == nil {
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
candidates := []string{}
|
|
||||||
if dir := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_SCRIPTS")); dir != "" {
|
|
||||||
candidates = append(candidates, filepath.Join(dir, name))
|
|
||||||
}
|
|
||||||
candidates = append(candidates, filepath.Join("scripts", "firewall", name))
|
|
||||||
for _, p := range candidates {
|
|
||||||
b, err := os.ReadFile(p)
|
|
||||||
if err == nil {
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, os.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := s.store.ListFirewallClients(a.TenantID)
|
|
||||||
if err != nil {
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
client, err := s.store.GetFirewallClient(a.TenantID, id)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var patch store.FirewallClientPatch
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client, err := s.store.UpdateFirewallClient(a.TenantID, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.firewallResolver.Reload(s.store)
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.firewallResolver.Reload(s.store)
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.firewallResolver.Reload(s.store)
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
|
|
||||||
var clientID *string
|
|
||||||
if scope == "client" {
|
|
||||||
cid := strings.TrimSpace(r.URL.Query().Get("client_id"))
|
|
||||||
if cid == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clientID = &cid
|
|
||||||
}
|
|
||||||
items, err := s.store.ListFirewallRules(a.TenantID, clientID)
|
|
||||||
if err != nil {
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
ClientID *string `json:"client_id"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
CommunityID *string `json:"community_id"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
Priority *int `json:"priority"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var clientID *string
|
|
||||||
if strings.TrimSpace(body.Scope) == "client" {
|
|
||||||
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid := strings.TrimSpace(*body.ClientID)
|
|
||||||
clientID = &cid
|
|
||||||
}
|
|
||||||
fwRule := &store.FirewallRuleCreate{
|
|
||||||
Priority: body.Priority,
|
|
||||||
Action: body.Action,
|
|
||||||
CommunityID: body.CommunityID,
|
|
||||||
Comment: body.Comment,
|
|
||||||
}
|
|
||||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
|
||||||
fwRule.CreatedByUserID = a.UserID
|
|
||||||
}
|
|
||||||
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusCreated, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var patch store.FirewallRulePatch
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rule, err := s.store.UpdateFirewallRule(a.TenantID, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
|
||||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
ClientID *string `json:"client_id"`
|
|
||||||
OrderedIDs []string `json:"ordered_ids"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var clientID *string
|
|
||||||
if strings.TrimSpace(body.Scope) == "client" {
|
|
||||||
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid := strings.TrimSpace(*body.ClientID)
|
|
||||||
clientID = &cid
|
|
||||||
}
|
|
||||||
if err := s.store.ReorderFirewallRules(a.TenantID, clientID, body.OrderedIDs); err != nil {
|
|
||||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid reorder")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go s.replicateFirewallStateToSpeakers(a.TenantID)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallBlocklist(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requireFirewall(w, a) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client, err := s.store.GetFirewallClient(a.TenantID, a.APIKeyID)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown firewall client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if client.Status != "approved" {
|
|
||||||
w.Header().Set("Retry-After", "60")
|
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "client pending approval")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.store.TouchFirewallClientLastSeen(client.ID, "cp", clientIP(r), r.UserAgent())
|
|
||||||
resp, err := s.buildFirewallBlocklist(r.Context(), client)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, errNoFirewallRevision) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("X-EvoBGP-Source", "cp")
|
|
||||||
w.Header().Set("X-EvoBGP-Revision-ID", resp.RevisionID)
|
|
||||||
w.Header().Set("X-EvoBGP-Generated-At", resp.GeneratedAt)
|
|
||||||
w.Header().Set("X-EvoBGP-Rules-Version", resp.RulesVersion)
|
|
||||||
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
for _, p := range resp.Prefixes {
|
|
||||||
_, _ = w.Write([]byte(p + "\n"))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requireFirewall(w, a) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
PrefixCount int `json:"prefix_count"`
|
|
||||||
IPCount int `json:"ip_count"`
|
|
||||||
PacketsDropped int64 `json:"packets_dropped"`
|
|
||||||
PacketsAccepted int64 `json:"packets_accepted"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
KernelMethod string `json:"kernel_method"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
src := strings.TrimSpace(body.Source)
|
|
||||||
if src == "" {
|
|
||||||
src = "cp"
|
|
||||||
}
|
|
||||||
_ = s.store.TouchFirewallClientLastApply(
|
|
||||||
a.APIKeyID, src, body.Status, body.Error,
|
|
||||||
body.PrefixCount, body.IPCount, body.PacketsDropped, body.PacketsAccepted,
|
|
||||||
)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requireFirewall(w, a) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
||||||
src := strings.TrimSpace(body.Source)
|
|
||||||
if src == "" {
|
|
||||||
src = "cp"
|
|
||||||
}
|
|
||||||
_ = s.store.TouchFirewallClientLastSeen(a.APIKeyID, src, clientIP(r), r.UserAgent())
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := authFromContext(r.Context())
|
|
||||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
client, err := s.store.GetFirewallClient(a.TenantID, id)
|
|
||||||
if err != nil {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := s.buildFirewallBlocklist(r.Context(), client)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, errNoFirewallRevision) {
|
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeInternalError(w, "internal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
var errNoFirewallRevision = errors.New("httpapi: no firewall revision")
|
|
||||||
|
|
||||||
type firewallBlocklistResponse struct {
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
RevisionID string `json:"revision_id"`
|
|
||||||
GeneratedAt string `json:"generated_at"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
RulesApplied int `json:"rules_applied"`
|
|
||||||
CommunitiesEvaluated int `json:"communities_evaluated"`
|
|
||||||
CommunitiesBlocked int `json:"communities_blocked"`
|
|
||||||
Prefixes []string `json:"prefixes"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
RulesVersion string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) buildFirewallBlocklist(ctx context.Context, client *store.FirewallClient) (*firewallBlocklistResponse, error) {
|
|
||||||
_ = ctx
|
|
||||||
revs, _, _ := s.store.ListRevisions(client.TenantID, "", "", 1)
|
|
||||||
if len(revs) == 0 {
|
|
||||||
return nil, errNoFirewallRevision
|
|
||||||
}
|
|
||||||
rev := revs[0]
|
|
||||||
prefixesByCommunity, commCount, err := s.loadPrefixesByCommunity(client.TenantID, rev.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rules, err := s.store.ListAllFirewallRulesForClient(client.TenantID, client.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fwRules := storeRulesToFirewall(rules)
|
|
||||||
blocked := firewall.Evaluate(client.ID, fwRules, prefixesByCommunity)
|
|
||||||
blockedComm := countBlockedCommunities(client.ID, fwRules, prefixesByCommunity)
|
|
||||||
hash := prefixListHash(blocked)
|
|
||||||
return &firewallBlocklistResponse{
|
|
||||||
ClientID: client.ID,
|
|
||||||
RevisionID: rev.ID,
|
|
||||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
||||||
Source: "cp",
|
|
||||||
RulesApplied: len(rules),
|
|
||||||
CommunitiesEvaluated: commCount,
|
|
||||||
CommunitiesBlocked: blockedComm,
|
|
||||||
Prefixes: blocked,
|
|
||||||
Total: len(blocked),
|
|
||||||
Hash: hash,
|
|
||||||
RulesVersion: firewall.RulesVersionHash(fwRules),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) loadPrefixesByCommunity(tenantID, revisionID string) (map[string][]string, int, error) {
|
|
||||||
out := make(map[string][]string)
|
|
||||||
communities := make(map[string]struct{})
|
|
||||||
cursor := ""
|
|
||||||
for {
|
|
||||||
rows, next, more := s.store.ListRevisionPrefixes(tenantID, revisionID, cursor, 5000)
|
|
||||||
for _, row := range rows {
|
|
||||||
key := ""
|
|
||||||
if row.CommunityID != nil {
|
|
||||||
key = strings.TrimSpace(*row.CommunityID)
|
|
||||||
}
|
|
||||||
communities[key] = struct{}{}
|
|
||||||
out[key] = append(out[key], strings.TrimSpace(row.Prefix))
|
|
||||||
}
|
|
||||||
if !more {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
}
|
|
||||||
return out, len(communities), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeRulesToFirewall(rules []*store.FirewallRule) []firewall.Rule {
|
|
||||||
out := make([]firewall.Rule, 0, len(rules))
|
|
||||||
for _, r := range rules {
|
|
||||||
var cid *string
|
|
||||||
if r.CommunityID != nil {
|
|
||||||
v := *r.CommunityID
|
|
||||||
cid = &v
|
|
||||||
}
|
|
||||||
var cl *string
|
|
||||||
if r.ClientID != nil {
|
|
||||||
v := *r.ClientID
|
|
||||||
cl = &v
|
|
||||||
}
|
|
||||||
out = append(out, firewall.Rule{
|
|
||||||
ClientID: cl,
|
|
||||||
Priority: r.Priority,
|
|
||||||
Action: r.Action,
|
|
||||||
CommunityID: cid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func countBlockedCommunities(clientID string, rules []firewall.Rule, prefixesByCommunity map[string][]string) int {
|
|
||||||
n := 0
|
|
||||||
for k := range prefixesByCommunity {
|
|
||||||
ordered := mergeRulesForCount(clientID, rules)
|
|
||||||
for _, r := range ordered {
|
|
||||||
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == k {
|
|
||||||
if strings.EqualFold(r.Action, "block") {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeRulesForCount(clientID string, rules []firewall.Rule) []firewall.Rule {
|
|
||||||
var clientRules, tenantRules []firewall.Rule
|
|
||||||
for _, r := range rules {
|
|
||||||
if r.ClientID != nil && *r.ClientID == clientID {
|
|
||||||
clientRules = append(clientRules, r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r.ClientID == nil {
|
|
||||||
tenantRules = append(tenantRules, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(clientRules, func(i, j int) bool { return clientRules[i].Priority < clientRules[j].Priority })
|
|
||||||
sort.Slice(tenantRules, func(i, j int) bool { return tenantRules[i].Priority < tenantRules[j].Priority })
|
|
||||||
out := append([]firewall.Rule{}, clientRules...)
|
|
||||||
return append(out, tenantRules...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prefixListHash(prefixes []string) string {
|
|
||||||
cp := append([]string(nil), prefixes...)
|
|
||||||
sort.Strings(cp)
|
|
||||||
sum := sha256.Sum256([]byte(strings.Join(cp, "\n")))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
|
||||||
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
|
|
||||||
parts := strings.Split(xff, ",")
|
|
||||||
return strings.TrimSpace(parts[0])
|
|
||||||
}
|
|
||||||
host := r.RemoteAddr
|
|
||||||
if i := strings.LastIndex(host, ":"); i >= 0 {
|
|
||||||
return host[:i]
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"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})
|
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -26,279 +22,37 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
|
|||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
client := ts.Client()
|
client := ts.Client()
|
||||||
|
|
||||||
tok := "evobgp_fw_testtoken123456789012345678901234"
|
cases := []struct {
|
||||||
enrollBody := `{"name":"web-01","hostname":"web-01.local","client_token":"` + tok + `","client_version":"test/1"}`
|
method string
|
||||||
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
|
path string
|
||||||
reqEnroll.Header.Set("Content-Type", "application/json")
|
auth bool // send a valid operator bearer token
|
||||||
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
|
}{
|
||||||
respEnroll, err := client.Do(reqEnroll)
|
{http.MethodPost, "/v1/firewall/enroll", false},
|
||||||
if err != nil {
|
{http.MethodGet, "/v1/firewall/install.sh", false},
|
||||||
t.Fatal(err)
|
{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() }()
|
for _, tc := range cases {
|
||||||
if respEnroll.StatusCode != http.StatusCreated {
|
req, err := http.NewRequest(tc.method, ts.URL+tc.path, nil)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
func() {
|
if tc.auth {
|
||||||
defer func() { _ = resp.Body.Close() }()
|
req.Header.Set("Authorization", "Bearer opkey")
|
||||||
if resp.StatusCode != http.StatusOK {
|
}
|
||||||
b, _ := io.ReadAll(resp.Body)
|
resp, err := client.Do(req)
|
||||||
t.Fatalf("%s status=%d body=%s", path, resp.StatusCode, b)
|
if err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
ct := resp.Header.Get("Content-Type")
|
}
|
||||||
if !strings.Contains(ct, "shellscript") {
|
_ = resp.Body.Close()
|
||||||
t.Fatalf("%s content-type=%q", path, ct)
|
if resp.StatusCode != http.StatusGone {
|
||||||
}
|
t.Fatalf("%s %s: want 410 got %d", tc.method, tc.path, resp.StatusCode)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/audit"
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/maintenance"
|
"evobgp/internal/maintenance"
|
||||||
"evobgp/internal/pgmonitor"
|
"evobgp/internal/pgmonitor"
|
||||||
@@ -38,11 +39,13 @@ type Server struct {
|
|||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
|
||||||
// Portal / dual-auth (JWT) configuration.
|
// Portal / dual-auth (JWT) configuration.
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
authIssuer string
|
authIssuer string
|
||||||
authPortalURL string
|
authPortalURL string
|
||||||
portalTenantID string
|
portalTenantID string
|
||||||
authRequired bool
|
authRequired bool
|
||||||
|
auditIngestSecret string
|
||||||
|
auditPusher *audit.PortalPusher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options configures the API server.
|
// Options configures the API server.
|
||||||
@@ -61,8 +64,10 @@ type Options struct {
|
|||||||
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
|
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
|
||||||
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
|
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)
|
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
|
||||||
PortalTenantID string // EVOBGP_PORTAL_TENANT_ID (single tenant scope for JWT users)
|
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
|
||||||
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
|
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
|
||||||
|
// AuditIngestSecret — AUTH_AUDIT_INGEST_SECRET for portal push (optional).
|
||||||
|
AuditIngestSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs Server and wiring for async jobs.
|
// New constructs Server and wiring for async jobs.
|
||||||
@@ -123,10 +128,12 @@ func New(opts Options) (*Server, error) {
|
|||||||
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
|
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
|
||||||
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
|
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
|
||||||
authRequired: opts.AuthRequired,
|
authRequired: opts.AuthRequired,
|
||||||
|
auditIngestSecret: strings.TrimSpace(opts.AuditIngestSecret),
|
||||||
}
|
}
|
||||||
if s.authIssuer == "" {
|
if s.authIssuer == "" {
|
||||||
s.authIssuer = "https://auth.shnt.top"
|
s.authIssuer = "https://auth.shnt.top"
|
||||||
}
|
}
|
||||||
|
s.initAuditPusher(s.authPortalURL, s.auditIngestSecret)
|
||||||
s.mux = http.NewServeMux()
|
s.mux = http.NewServeMux()
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
@@ -940,6 +940,50 @@ func uint32FromSettingsMap(m map[string]any, key string) uint32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolFromSettingsMap(m map[string]any, key string, defaultVal bool) bool {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return x
|
||||||
|
case float64:
|
||||||
|
return x != 0
|
||||||
|
case int:
|
||||||
|
return x != 0
|
||||||
|
case string:
|
||||||
|
s := strings.ToLower(strings.TrimSpace(x))
|
||||||
|
if s == "true" || s == "1" || s == "yes" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s == "false" || s == "0" || s == "no" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPeerDiscoveryBirdFragment(st store.Backend, tenantID string) (string, error) {
|
||||||
|
settings, err := st.ListGlobalSettings(tenantID)
|
||||||
|
if err != nil || settings == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !boolFromSettingsMap(settings, "peer_discovery_enabled", false) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
rangesV4 := birdfmt.ParseDiscoveryRanges(stringFromSettingsMap(settings, "peer_discovery_ranges_v4"))
|
||||||
|
rangesV6 := birdfmt.ParseDiscoveryRanges(stringFromSettingsMap(settings, "peer_discovery_ranges_v6"))
|
||||||
|
if len(rangesV4) == 0 && len(rangesV6) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return birdfmt.RenderDynamicBGPDiscovery(birdfmt.DynamicBGPDiscoveryOptions{
|
||||||
|
RangesV4: rangesV4,
|
||||||
|
RangesV6: rangesV6,
|
||||||
|
RequireExternal: boolFromSettingsMap(settings, "peer_discovery_require_external", true),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func intFromSettingsMap(m map[string]any, key string) int {
|
func intFromSettingsMap(m map[string]any, key string) int {
|
||||||
v, ok := m[key]
|
v, ok := m[key]
|
||||||
if !ok || v == nil {
|
if !ok || v == nil {
|
||||||
@@ -1004,6 +1048,11 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals)
|
|||||||
peers := st.ListPeers(tenantID)
|
peers := st.ListPeers(tenantID)
|
||||||
var parts []string
|
var parts []string
|
||||||
parts = append(parts, birdfmt.ManagedBanner("peers"))
|
parts = append(parts, birdfmt.ManagedBanner("peers"))
|
||||||
|
if disc, err := renderPeerDiscoveryBirdFragment(st, tenantID); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if disc != "" {
|
||||||
|
parts = append(parts, disc)
|
||||||
|
}
|
||||||
for _, p := range peers {
|
for _, p := range peers {
|
||||||
if p == nil || !p.Enabled {
|
if p == nil || !p.Enabled {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1237,12 +1237,32 @@ func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) {
|
func (p *Postgres) GetCommunity(tenantID, idOrKey string) (*store.Community, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
key := strings.TrimSpace(idOrKey)
|
||||||
|
if key == "" {
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
var c store.Community
|
var c store.Community
|
||||||
c.TenantID = tenantID
|
c.TenantID = tenantID
|
||||||
err := p.pool.QueryRow(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
// Prefer UUID id; fall back to community / title so clients that store the
|
||||||
&c.ID, &c.Community, &c.Title, &c.ValueJSON)
|
// autocomplete label (Base UI {value,label} → label) still resolve.
|
||||||
|
var err error
|
||||||
|
if _, perr := uuid.Parse(key); perr == nil {
|
||||||
|
err = p.pool.QueryRow(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, key, tenantID).Scan(
|
||||||
|
&c.ID, &c.Community, &c.Title, &c.ValueJSON)
|
||||||
|
} else {
|
||||||
|
err = p.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text, community, title, value_json::text FROM bgp_community
|
||||||
|
WHERE tenant_id=$1 AND (
|
||||||
|
community = $2
|
||||||
|
OR title = $2
|
||||||
|
OR (NULLIF(trim(title), '') IS NOT NULL AND (community || ' · ' || title) = $2)
|
||||||
|
)
|
||||||
|
ORDER BY community
|
||||||
|
LIMIT 1`, tenantID, key).Scan(
|
||||||
|
&c.ID, &c.Community, &c.Title, &c.ValueJSON)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
@@ -1252,6 +1272,91 @@ func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) {
|
|||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]store.PrefixRow, string, bool, error) {
|
||||||
|
comm, err := p.GetCommunity(tenantID, communityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
resolvedID := comm.ID
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
if limit > 5000 {
|
||||||
|
limit = 5000
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
if cursor != "" {
|
||||||
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||||
|
off = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
useSnap := prefixSnapshotTableExists(ctx, p.pool)
|
||||||
|
var rows pgx.Rows
|
||||||
|
if useSnap {
|
||||||
|
rows, err = p.pool.Query(ctx, `
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT DISTINCT ON (module_id) id, prefix_snapshot_id
|
||||||
|
FROM config_revision
|
||||||
|
WHERE tenant_id = $1::uuid AND module_id IS NOT NULL
|
||||||
|
ORDER BY module_id, created_at DESC
|
||||||
|
),
|
||||||
|
combined AS (
|
||||||
|
SELECT rmp.prefix::text AS prefix, COALESCE(rmp.source, '') AS source
|
||||||
|
FROM revision_materialized_prefix rmp
|
||||||
|
JOIN latest l ON l.id = rmp.revision_id
|
||||||
|
WHERE l.prefix_snapshot_id IS NULL AND rmp.community_id = $2::uuid
|
||||||
|
UNION
|
||||||
|
SELECT psr.prefix::text, COALESCE(psr.source, '')
|
||||||
|
FROM prefix_snapshot_row psr
|
||||||
|
JOIN latest l ON l.prefix_snapshot_id = psr.snapshot_id
|
||||||
|
WHERE l.prefix_snapshot_id IS NOT NULL AND psr.community_id = $2::uuid
|
||||||
|
)
|
||||||
|
SELECT prefix, source FROM combined
|
||||||
|
ORDER BY prefix
|
||||||
|
LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off)
|
||||||
|
} else {
|
||||||
|
rows, err = p.pool.Query(ctx, `
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT DISTINCT ON (module_id) id
|
||||||
|
FROM config_revision
|
||||||
|
WHERE tenant_id = $1::uuid AND module_id IS NOT NULL
|
||||||
|
ORDER BY module_id, created_at DESC
|
||||||
|
)
|
||||||
|
SELECT DISTINCT rmp.prefix::text, COALESCE(rmp.source, '')
|
||||||
|
FROM revision_materialized_prefix rmp
|
||||||
|
JOIN latest l ON l.id = rmp.revision_id
|
||||||
|
WHERE rmp.community_id = $2::uuid
|
||||||
|
ORDER BY 1
|
||||||
|
LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var all []store.PrefixRow
|
||||||
|
for rows.Next() {
|
||||||
|
var pr store.PrefixRow
|
||||||
|
if err := rows.Scan(&pr.Prefix, &pr.Source); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pr.CommunityID = &resolvedID
|
||||||
|
all = append(all, pr)
|
||||||
|
}
|
||||||
|
more := len(all) > limit
|
||||||
|
if more {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if more {
|
||||||
|
next = fmt.Sprintf("%d", off+limit)
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
return all, next, more, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store.Community, error) {
|
func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store.Community, error) {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil, store.ErrInvalidInput
|
return nil, store.ErrInvalidInput
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppendAudit inserts a tenant-scoped audit row.
|
||||||
|
func (p *Postgres) AppendAudit(in store.AuditAppendInput) (*store.AuditEntry, error) {
|
||||||
|
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
sev := strings.TrimSpace(in.Severity)
|
||||||
|
if sev == "" {
|
||||||
|
sev = store.AuditSeverityInfo
|
||||||
|
}
|
||||||
|
if !store.ValidAuditSeverity(sev) {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
id := uuid.NewString()
|
||||||
|
eventID := "bgp-" + uuid.NewString()
|
||||||
|
var detailJSON []byte
|
||||||
|
if in.Details != nil {
|
||||||
|
detailJSON, _ = json.Marshal(in.Details)
|
||||||
|
}
|
||||||
|
var createdAt time.Time
|
||||||
|
err := p.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO audit_log
|
||||||
|
(id, tenant_id, event_id, source_app, action, severity,
|
||||||
|
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
|
||||||
|
target_type, target_id, summary, details_json, ip, created_at)
|
||||||
|
VALUES ($1, $2, $3, 'bgp', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, now())
|
||||||
|
RETURNING created_at`,
|
||||||
|
id, strings.TrimSpace(in.TenantID), eventID, strings.TrimSpace(in.Action), sev,
|
||||||
|
nullIfEmpty(in.ActorUserID), nullIfEmpty(in.ActorEmail), nullIfEmpty(in.ActorName),
|
||||||
|
nullIfEmpty(in.ActorAPIKeyPrefix), nullIfEmpty(in.TargetType), nullIfEmpty(in.TargetID),
|
||||||
|
strings.TrimSpace(in.Summary), nullJSONBytes(detailJSON), nullIfEmpty(in.IP),
|
||||||
|
).Scan(&createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &store.AuditEntry{
|
||||||
|
ID: id,
|
||||||
|
TenantID: strings.TrimSpace(in.TenantID),
|
||||||
|
EventID: eventID,
|
||||||
|
SourceApp: store.AuditSourceAppBGP,
|
||||||
|
Action: strings.TrimSpace(in.Action),
|
||||||
|
Severity: sev,
|
||||||
|
ActorUserID: strings.TrimSpace(in.ActorUserID),
|
||||||
|
ActorEmail: strings.TrimSpace(in.ActorEmail),
|
||||||
|
ActorName: strings.TrimSpace(in.ActorName),
|
||||||
|
ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix),
|
||||||
|
TargetType: strings.TrimSpace(in.TargetType),
|
||||||
|
TargetID: strings.TrimSpace(in.TargetID),
|
||||||
|
Summary: strings.TrimSpace(in.Summary),
|
||||||
|
Details: in.Details,
|
||||||
|
IP: strings.TrimSpace(in.IP),
|
||||||
|
CreatedAt: createdAt.UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudit returns paginated audit rows for a tenant.
|
||||||
|
func (p *Postgres) ListAudit(tenantID, cursor string, limit int, filter store.AuditListFilter) ([]*store.AuditEntry, string, bool, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
if cursor != "" {
|
||||||
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||||
|
off = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
args := []any{tenantID}
|
||||||
|
where := "tenant_id = $1"
|
||||||
|
argN := 2
|
||||||
|
if a := strings.TrimSpace(filter.Action); a != "" {
|
||||||
|
where += " AND action = $" + strconv.Itoa(argN)
|
||||||
|
args = append(args, a)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(filter.Severity); s != "" {
|
||||||
|
where += " AND severity = $" + strconv.Itoa(argN)
|
||||||
|
args = append(args, s)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
args = append(args, limit+1, off)
|
||||||
|
q := `
|
||||||
|
SELECT id, tenant_id, event_id, source_app, action, severity,
|
||||||
|
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
|
||||||
|
target_type, target_id, summary, details_json, ip, created_at, portal_pushed_at
|
||||||
|
FROM audit_log
|
||||||
|
WHERE ` + where + `
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||||
|
rows, err := p.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*store.AuditEntry
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := scanAuditEntry(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
more := len(out) > limit
|
||||||
|
if more {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if more {
|
||||||
|
next = strconv.Itoa(off + limit)
|
||||||
|
}
|
||||||
|
return out, next, more, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAuditPortalPushed sets portal_pushed_at for a row.
|
||||||
|
func (p *Postgres) MarkAuditPortalPushed(id string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
tag, err := p.pool.Exec(ctx, `UPDATE audit_log SET portal_pushed_at = now() WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAuditEntry(scan func(dest ...any) error) (*store.AuditEntry, error) {
|
||||||
|
var row store.AuditEntry
|
||||||
|
var actorUserID, actorEmail, actorName, actorPrefix, targetType, targetID, ip *string
|
||||||
|
var detailRaw []byte
|
||||||
|
var portalPushed *time.Time
|
||||||
|
if err := scan(
|
||||||
|
&row.ID, &row.TenantID, &row.EventID, &row.SourceApp, &row.Action, &row.Severity,
|
||||||
|
&actorUserID, &actorEmail, &actorName, &actorPrefix,
|
||||||
|
&targetType, &targetID, &row.Summary, &detailRaw, &ip, &row.CreatedAt, &portalPushed,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.CreatedAt = row.CreatedAt.UTC()
|
||||||
|
if actorUserID != nil {
|
||||||
|
row.ActorUserID = *actorUserID
|
||||||
|
}
|
||||||
|
if actorEmail != nil {
|
||||||
|
row.ActorEmail = *actorEmail
|
||||||
|
}
|
||||||
|
if actorName != nil {
|
||||||
|
row.ActorName = *actorName
|
||||||
|
}
|
||||||
|
if actorPrefix != nil {
|
||||||
|
row.ActorAPIKeyPrefix = *actorPrefix
|
||||||
|
}
|
||||||
|
if targetType != nil {
|
||||||
|
row.TargetType = *targetType
|
||||||
|
}
|
||||||
|
if targetID != nil {
|
||||||
|
row.TargetID = *targetID
|
||||||
|
}
|
||||||
|
if ip != nil {
|
||||||
|
row.IP = *ip
|
||||||
|
}
|
||||||
|
if len(detailRaw) > 0 {
|
||||||
|
_ = json.Unmarshal(detailRaw, &row.Details)
|
||||||
|
}
|
||||||
|
if portalPushed != nil {
|
||||||
|
t := portalPushed.UTC()
|
||||||
|
row.PortalPushedAt = &t
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Postgres) ListPeerDiscoveries(tenantID, status string) ([]*store.BGPPeerDiscovery, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
status = strings.TrimSpace(strings.ToLower(status))
|
||||||
|
q := `
|
||||||
|
SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
|
||||||
|
neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
|
||||||
|
status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
|
||||||
|
FROM bgp_peer_discovery WHERE tenant_id=$1`
|
||||||
|
args := []any{tenantID}
|
||||||
|
if status != "" {
|
||||||
|
q += ` AND status=$2`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
q += ` ORDER BY last_seen_at DESC`
|
||||||
|
rows, err := p.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*store.BGPPeerDiscovery
|
||||||
|
for rows.Next() {
|
||||||
|
d, err := scanPeerDiscovery(rows)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) GetPeerDiscovery(tenantID, id string) (*store.BGPPeerDiscovery, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
row := p.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
|
||||||
|
neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
|
||||||
|
status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
|
||||||
|
FROM bgp_peer_discovery WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||||
|
d, err := scanPeerDiscovery(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerDiscoveryScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPeerDiscovery(row peerDiscoveryScanner) (*store.BGPPeerDiscovery, error) {
|
||||||
|
var d store.BGPPeerDiscovery
|
||||||
|
var first, last time.Time
|
||||||
|
err := row.Scan(
|
||||||
|
&d.ID, &d.TenantID, &d.SpeakerID, &d.NeighborID,
|
||||||
|
&d.Neighbor, &d.RemoteASN, &d.ProtocolName, &d.SessionState,
|
||||||
|
&d.Status, &first, &last, &d.ApprovedPeerID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.FirstSeenAt = first.UTC()
|
||||||
|
d.LastSeenAt = last.UTC()
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) UpsertPeerDiscovery(tenantID string, in *store.PeerDiscoveryUpsert) (*store.BGPPeerDiscovery, error) {
|
||||||
|
if in == nil {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
neighbor, ok := store.NormalizePeerNeighborString(in.Neighbor)
|
||||||
|
if !ok {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
neighborID := strings.TrimSpace(in.NeighborID)
|
||||||
|
seenAt := in.SeenAt
|
||||||
|
if seenAt.IsZero() {
|
||||||
|
seenAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var existingID string
|
||||||
|
if neighborID != "" {
|
||||||
|
_ = p.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text FROM bgp_peer_discovery
|
||||||
|
WHERE tenant_id=$1 AND neighbor_id=$2 LIMIT 1`, tenantID, neighborID).Scan(&existingID)
|
||||||
|
}
|
||||||
|
if existingID == "" {
|
||||||
|
_ = p.pool.QueryRow(ctx, `
|
||||||
|
SELECT id::text FROM bgp_peer_discovery
|
||||||
|
WHERE tenant_id=$1 AND neighbor=$2::inet AND remote_asn=$3 AND neighbor_id='' LIMIT 1`,
|
||||||
|
tenantID, neighbor, in.RemoteASN).Scan(&existingID)
|
||||||
|
}
|
||||||
|
if existingID != "" {
|
||||||
|
cur, err := p.GetPeerDiscovery(tenantID, existingID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cur.Status == store.PeerDiscoveryRejected {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
var sp any
|
||||||
|
if s := strings.TrimSpace(in.SpeakerID); s != "" {
|
||||||
|
sp = s
|
||||||
|
}
|
||||||
|
_, err = p.pool.Exec(ctx, `
|
||||||
|
UPDATE bgp_peer_discovery SET
|
||||||
|
neighbor=$3::inet, remote_asn=$4,
|
||||||
|
neighbor_id=CASE WHEN $5 <> '' THEN $5 ELSE neighbor_id END,
|
||||||
|
protocol_name=$6, session_state=$7, last_seen_at=$8,
|
||||||
|
speaker_id=COALESCE($9::uuid, speaker_id),
|
||||||
|
updated_at=now()
|
||||||
|
WHERE id=$1 AND tenant_id=$2 AND status <> 'rejected'`,
|
||||||
|
existingID, tenantID, neighbor, in.RemoteASN, neighborID,
|
||||||
|
strings.TrimSpace(in.ProtocolName), strings.TrimSpace(in.SessionState), seenAt, sp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.GetPeerDiscovery(tenantID, existingID)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
var sp any
|
||||||
|
if s := strings.TrimSpace(in.SpeakerID); s != "" {
|
||||||
|
sp = s
|
||||||
|
}
|
||||||
|
_, err := p.pool.Exec(ctx, `
|
||||||
|
INSERT INTO bgp_peer_discovery (
|
||||||
|
id, tenant_id, speaker_id, neighbor_id, neighbor, remote_asn,
|
||||||
|
protocol_name, session_state, status, first_seen_at, last_seen_at
|
||||||
|
) VALUES ($1,$2,$3::uuid,$4,$5::inet,$6,$7,$8,'pending',$9,$9)`,
|
||||||
|
id, tenantID, sp, neighborID, neighbor, in.RemoteASN,
|
||||||
|
strings.TrimSpace(in.ProtocolName), strings.TrimSpace(in.SessionState), seenAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.GetPeerDiscovery(tenantID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) ApprovePeerDiscovery(tenantID, id string, in *store.PeerDiscoveryApproveInput) (*store.BGPPeer, *store.BGPPeerDiscovery, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := p.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
d, err := p.getPeerDiscoveryTx(ctx, tx, tenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if d.Status != store.PeerDiscoveryPending {
|
||||||
|
return nil, nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if d.RemoteASN == 0 {
|
||||||
|
return nil, nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
neighbor, ok := store.NormalizePeerNeighborString(d.Neighbor)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
name := ""
|
||||||
|
enabled := true
|
||||||
|
var speakerID *string
|
||||||
|
if in != nil {
|
||||||
|
name = strings.TrimSpace(in.Name)
|
||||||
|
if in.Enabled != nil {
|
||||||
|
enabled = *in.Enabled
|
||||||
|
}
|
||||||
|
if in.SpeakerID != nil {
|
||||||
|
v := strings.TrimSpace(*in.SpeakerID)
|
||||||
|
if v != "" {
|
||||||
|
speakerID = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
if d.NeighborID != "" {
|
||||||
|
name = "discovered-" + d.NeighborID
|
||||||
|
} else {
|
||||||
|
name = "discovered-" + neighbor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if speakerID == nil && strings.TrimSpace(d.SpeakerID) != "" {
|
||||||
|
sp := d.SpeakerID
|
||||||
|
speakerID = &sp
|
||||||
|
}
|
||||||
|
|
||||||
|
peerID := uuid.NewString()
|
||||||
|
meta, _ := json.Marshal(map[string]any{"name": name, "session_state": d.SessionState})
|
||||||
|
var sp any
|
||||||
|
if speakerID != nil {
|
||||||
|
sp = *speakerID
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
|
||||||
|
VALUES ($1,$2,$3::uuid,$4::inet,$5,$6,'{}'::jsonb,$7::jsonb)`,
|
||||||
|
peerID, tenantID, sp, neighbor, d.RemoteASN, enabled, string(meta))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE bgp_peer_discovery SET status='approved', approved_peer_id=$3::uuid, last_seen_at=now(), updated_at=now()
|
||||||
|
WHERE id=$1 AND tenant_id=$2`, id, tenantID, peerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
peer, err := p.GetPeer(tenantID, peerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
disc, err := p.GetPeerDiscovery(tenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return peer, disc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) RejectPeerDiscovery(tenantID, id string) (*store.BGPPeerDiscovery, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cur, err := p.GetPeerDiscovery(tenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cur.Status == store.PeerDiscoveryApproved {
|
||||||
|
return nil, store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
tag, err := p.pool.Exec(ctx, `
|
||||||
|
UPDATE bgp_peer_discovery SET status='rejected', last_seen_at=now(), updated_at=now()
|
||||||
|
WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return p.GetPeerDiscovery(tenantID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) getPeerDiscoveryTx(ctx context.Context, tx pgx.Tx, tenantID, id string) (*store.BGPPeerDiscovery, error) {
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
|
||||||
|
neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
|
||||||
|
status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
|
||||||
|
FROM bgp_peer_discovery WHERE id=$1 AND tenant_id=$2 FOR UPDATE`, id, tenantID)
|
||||||
|
d, err := scanPeerDiscovery(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuditSourceAppBGP = "bgp"
|
||||||
|
AuditTargetAppResource = "app_resource"
|
||||||
|
AuditSeverityInfo = "info"
|
||||||
|
AuditSeverityWarning = "warning"
|
||||||
|
AuditSeverityCritical = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditEntry is a persisted CRUD / settings audit row (local + portal ingest).
|
||||||
|
type AuditEntry struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
EventID string
|
||||||
|
SourceApp string
|
||||||
|
Action string
|
||||||
|
Severity string
|
||||||
|
ActorUserID string
|
||||||
|
ActorEmail string
|
||||||
|
ActorName string
|
||||||
|
ActorAPIKeyPrefix string
|
||||||
|
TargetType string
|
||||||
|
TargetID string
|
||||||
|
Summary string
|
||||||
|
Details map[string]any
|
||||||
|
IP string
|
||||||
|
CreatedAt time.Time
|
||||||
|
PortalPushedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditAppendInput is input for AppendAudit.
|
||||||
|
type AuditAppendInput struct {
|
||||||
|
TenantID string
|
||||||
|
Action string
|
||||||
|
Severity string
|
||||||
|
ActorUserID string
|
||||||
|
ActorEmail string
|
||||||
|
ActorName string
|
||||||
|
ActorAPIKeyPrefix string
|
||||||
|
TargetType string
|
||||||
|
TargetID string
|
||||||
|
Summary string
|
||||||
|
Details map[string]any
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditListFilter optional query filters for ListAudit.
|
||||||
|
type AuditListFilter struct {
|
||||||
|
Action string
|
||||||
|
Severity string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidAuditSeverity reports whether s is an allowed severity.
|
||||||
|
func ValidAuditSeverity(s string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case AuditSeverityInfo, AuditSeverityWarning, AuditSeverityCritical:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ type Backend interface {
|
|||||||
CreateCommunity(tenantID string, in *Community) (*Community, error)
|
CreateCommunity(tenantID string, in *Community) (*Community, error)
|
||||||
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
||||||
DeleteCommunity(tenantID, id string) error
|
DeleteCommunity(tenantID, id string) error
|
||||||
|
// ListCommunityPrefixes returns unique prefixes tagged with community from latest revision per module.
|
||||||
|
ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) (prefixes []PrefixRow, nextCursor string, hasMore bool, err error)
|
||||||
|
|
||||||
// ListPeers returns all BGP peers for a tenant (control plane may paginate in httpapi).
|
// ListPeers returns all BGP peers for a tenant (control plane may paginate in httpapi).
|
||||||
ListPeers(tenantID string) []*BGPPeer
|
ListPeers(tenantID string) []*BGPPeer
|
||||||
@@ -68,6 +70,13 @@ type Backend interface {
|
|||||||
UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error)
|
UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error)
|
||||||
DeletePeer(tenantID, id string) error
|
DeletePeer(tenantID, id string) error
|
||||||
|
|
||||||
|
// Peer discovery (dynamic BGP quarantine → approve/reject).
|
||||||
|
ListPeerDiscoveries(tenantID, status string) ([]*BGPPeerDiscovery, error)
|
||||||
|
GetPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error)
|
||||||
|
UpsertPeerDiscovery(tenantID string, in *PeerDiscoveryUpsert) (*BGPPeerDiscovery, error)
|
||||||
|
ApprovePeerDiscovery(tenantID, id string, in *PeerDiscoveryApproveInput) (*BGPPeer, *BGPPeerDiscovery, error)
|
||||||
|
RejectPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error)
|
||||||
|
|
||||||
ListSpeakersForTenant(tenantID string) []*Speaker
|
ListSpeakersForTenant(tenantID string) []*Speaker
|
||||||
GetSpeaker(tenantID, speakerID string) (*Speaker, error)
|
GetSpeaker(tenantID, speakerID string) (*Speaker, error)
|
||||||
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
||||||
@@ -132,6 +141,11 @@ type Backend interface {
|
|||||||
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
|
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
|
||||||
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
|
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
|
||||||
|
|
||||||
|
// CRUD audit log (tenant-scoped; optional portal ingest push from httpapi).
|
||||||
|
AppendAudit(in AuditAppendInput) (*AuditEntry, error)
|
||||||
|
ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error)
|
||||||
|
MarkAuditPortalPushed(id string) error
|
||||||
|
|
||||||
// Firewall blocklist clients and policy rules.
|
// Firewall blocklist clients and policy rules.
|
||||||
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
|
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
|
||||||
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
|
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type Memory struct {
|
|||||||
|
|
||||||
peers map[string]*BGPPeer
|
peers map[string]*BGPPeer
|
||||||
|
|
||||||
|
peerDiscoveries map[string]*BGPPeerDiscovery
|
||||||
|
|
||||||
dohProfiles map[string]*DohProfile
|
dohProfiles map[string]*DohProfile
|
||||||
communities map[string]*Community
|
communities map[string]*Community
|
||||||
cdnSources map[string]*CDNSource
|
cdnSources map[string]*CDNSource
|
||||||
@@ -50,6 +52,7 @@ type Memory struct {
|
|||||||
maintenancePolicies map[string]*MaintenancePolicy
|
maintenancePolicies map[string]*MaintenancePolicy
|
||||||
maintConfigAudit []*MaintenancePolicyConfigAudit
|
maintConfigAudit []*MaintenancePolicyConfigAudit
|
||||||
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
||||||
|
auditLog []*AuditEntry
|
||||||
|
|
||||||
// DemoIDs valid after SeedDemo()
|
// DemoIDs valid after SeedDemo()
|
||||||
demoTenantID string
|
demoTenantID string
|
||||||
@@ -142,6 +145,7 @@ func NewMemory() *Memory {
|
|||||||
speakers: make(map[string]*Speaker),
|
speakers: make(map[string]*Speaker),
|
||||||
publishedRevision: make(map[string]publishedInfo),
|
publishedRevision: make(map[string]publishedInfo),
|
||||||
peers: make(map[string]*BGPPeer),
|
peers: make(map[string]*BGPPeer),
|
||||||
|
peerDiscoveries: make(map[string]*BGPPeerDiscovery),
|
||||||
dohProfiles: make(map[string]*DohProfile),
|
dohProfiles: make(map[string]*DohProfile),
|
||||||
communities: make(map[string]*Community),
|
communities: make(map[string]*Community),
|
||||||
cdnSources: make(map[string]*CDNSource),
|
cdnSources: make(map[string]*CDNSource),
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Memory) AppendAudit(in AuditAppendInput) (*AuditEntry, error) {
|
||||||
|
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
sev := strings.TrimSpace(in.Severity)
|
||||||
|
if sev == "" {
|
||||||
|
sev = AuditSeverityInfo
|
||||||
|
}
|
||||||
|
if !ValidAuditSeverity(sev) {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
row := &AuditEntry{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
TenantID: strings.TrimSpace(in.TenantID),
|
||||||
|
EventID: "bgp-" + uuid.NewString(),
|
||||||
|
SourceApp: AuditSourceAppBGP,
|
||||||
|
Action: strings.TrimSpace(in.Action),
|
||||||
|
Severity: sev,
|
||||||
|
ActorUserID: strings.TrimSpace(in.ActorUserID),
|
||||||
|
ActorEmail: strings.TrimSpace(in.ActorEmail),
|
||||||
|
ActorName: strings.TrimSpace(in.ActorName),
|
||||||
|
ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix),
|
||||||
|
TargetType: strings.TrimSpace(in.TargetType),
|
||||||
|
TargetID: strings.TrimSpace(in.TargetID),
|
||||||
|
Summary: strings.TrimSpace(in.Summary),
|
||||||
|
Details: in.Details,
|
||||||
|
IP: strings.TrimSpace(in.IP),
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.auditLog = append(m.auditLog, row)
|
||||||
|
return cloneAuditEntry(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var filtered []*AuditEntry
|
||||||
|
for _, row := range m.auditLog {
|
||||||
|
if row.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a := strings.TrimSpace(filter.Action); a != "" && row.Action != a {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(filter.Severity); s != "" && row.Severity != s {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, row)
|
||||||
|
}
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
|
||||||
|
return filtered[i].ID > filtered[j].ID
|
||||||
|
}
|
||||||
|
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||||
|
})
|
||||||
|
off := parseMaintCursor(cursor)
|
||||||
|
end := off + limit
|
||||||
|
next := ""
|
||||||
|
hasMore := false
|
||||||
|
if end > len(filtered) {
|
||||||
|
end = len(filtered)
|
||||||
|
} else if end < len(filtered) {
|
||||||
|
hasMore = true
|
||||||
|
next = formatMaintCursor(end)
|
||||||
|
}
|
||||||
|
if off >= len(filtered) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
out := make([]*AuditEntry, end-off)
|
||||||
|
for i := off; i < end; i++ {
|
||||||
|
out[i-off] = cloneAuditEntry(filtered[i])
|
||||||
|
}
|
||||||
|
return out, next, hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) MarkAuditPortalPushed(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, row := range m.auditLog {
|
||||||
|
if row.ID == id {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
row.PortalPushedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAuditEntry(row *AuditEntry) *AuditEntry {
|
||||||
|
if row == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *row
|
||||||
|
if row.Details != nil {
|
||||||
|
cp.Details = make(map[string]any, len(row.Details))
|
||||||
|
for k, v := range row.Details {
|
||||||
|
cp.Details[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMemoryAppendAndListAudit(t *testing.T) {
|
||||||
|
m := NewMemory()
|
||||||
|
tenantA := "tenant-a"
|
||||||
|
tenantB := "tenant-b"
|
||||||
|
|
||||||
|
entry, err := m.AppendAudit(AuditAppendInput{
|
||||||
|
TenantID: tenantA,
|
||||||
|
Action: "bgp.module.create",
|
||||||
|
Summary: "Created module test",
|
||||||
|
TargetID: "mod-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if entry == nil || entry.EventID == "" || entry.SourceApp != AuditSourceAppBGP {
|
||||||
|
t.Fatalf("unexpected entry: %+v", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := m.AppendAudit(AuditAppendInput{
|
||||||
|
TenantID: tenantB,
|
||||||
|
Action: "bgp.peer.delete",
|
||||||
|
Summary: "Deleted peer",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, _, hasMore, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || hasMore {
|
||||||
|
t.Fatalf("items=%d hasMore=%v", len(items), hasMore)
|
||||||
|
}
|
||||||
|
if items[0].Action != "bgp.module.create" {
|
||||||
|
t.Fatalf("action=%s", items[0].Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{Action: "bgp.peer.delete"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(filtered) != 0 {
|
||||||
|
t.Fatalf("expected empty filter result, got %d", len(filtered))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.MarkAuditPortalPushed(entry.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items2, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if items2[0].PortalPushedAt == nil {
|
||||||
|
t.Fatal("expected portal_pushed_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryAppendAuditValidation(t *testing.T) {
|
||||||
|
m := NewMemory()
|
||||||
|
if _, err := m.AppendAudit(AuditAppendInput{}); err != ErrInvalidInput {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.AppendAudit(AuditAppendInput{TenantID: "t", Action: "x", Summary: "s", Severity: "bad"}); err != ErrInvalidInput {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -583,14 +585,97 @@ func (m *Memory) ListCommunities(tenantID string) ([]*Community, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) GetCommunity(tenantID, id string) (*Community, error) {
|
func (m *Memory) GetCommunity(tenantID, idOrKey string) (*Community, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
c, ok := m.communities[id]
|
key := strings.TrimSpace(idOrKey)
|
||||||
if !ok || c.TenantID != tenantID {
|
if key == "" {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return c, nil
|
if c, ok := m.communities[key]; ok && c.TenantID == tenantID {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
for _, c := range m.communities {
|
||||||
|
if c.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Community == key || c.Title == key {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Title) != "" && c.Community+" · "+c.Title == key {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]PrefixRow, string, bool, error) {
|
||||||
|
commRow, err := m.GetCommunity(tenantID, communityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
resolvedID := commRow.ID
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
if limit > 5000 {
|
||||||
|
limit = 5000
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
if cursor != "" {
|
||||||
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||||
|
off = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
latestByModule := map[string]*Revision{}
|
||||||
|
for _, rev := range m.revisions {
|
||||||
|
if rev.TenantID != tenantID || strings.TrimSpace(rev.ModuleID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur := latestByModule[rev.ModuleID]
|
||||||
|
if cur == nil || rev.CreatedAt.After(cur.CreatedAt) {
|
||||||
|
latestByModule[rev.ModuleID] = rev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var all []PrefixRow
|
||||||
|
for _, rev := range latestByModule {
|
||||||
|
for _, pr := range m.revPrefixes[rev.ID] {
|
||||||
|
if pr.CommunityID == nil || *pr.CommunityID != resolvedID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx := strings.TrimSpace(pr.Prefix)
|
||||||
|
if pfx == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[pfx]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pfx] = struct{}{}
|
||||||
|
all = append(all, PrefixRow{Prefix: pfx, CommunityID: &resolvedID, Source: pr.Source})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].Prefix < all[j].Prefix })
|
||||||
|
if off > len(all) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
end := off + limit
|
||||||
|
more := false
|
||||||
|
next := ""
|
||||||
|
if end < len(all) {
|
||||||
|
more = true
|
||||||
|
next = strconv.Itoa(end)
|
||||||
|
all = all[off:end]
|
||||||
|
} else {
|
||||||
|
all = all[off:]
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
return all, next, more, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) {
|
func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Memory) ListPeerDiscoveries(tenantID, status string) ([]*BGPPeerDiscovery, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
status = strings.TrimSpace(strings.ToLower(status))
|
||||||
|
var out []*BGPPeerDiscovery
|
||||||
|
for _, d := range m.peerDiscoveries {
|
||||||
|
if d == nil || d.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status != "" && !strings.EqualFold(d.Status, status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, copyPeerDiscovery(d))
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return out[i].LastSeenAt.After(out[j].LastSeenAt)
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) GetPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
d, ok := m.peerDiscoveries[id]
|
||||||
|
if !ok || d.TenantID != tenantID {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return copyPeerDiscovery(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) UpsertPeerDiscovery(tenantID string, in *PeerDiscoveryUpsert) (*BGPPeerDiscovery, error) {
|
||||||
|
if in == nil {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
neighbor, ok := NormalizePeerNeighborString(in.Neighbor)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
neighborID := strings.TrimSpace(in.NeighborID)
|
||||||
|
seenAt := in.SeenAt
|
||||||
|
if seenAt.IsZero() {
|
||||||
|
seenAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if _, ok := m.tenants[tenantID]; !ok {
|
||||||
|
return nil, ErrTenantScope
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := m.findPeerDiscoveryLocked(tenantID, neighborID, neighbor, in.RemoteASN)
|
||||||
|
if existing != nil {
|
||||||
|
if existing.Status == PeerDiscoveryRejected {
|
||||||
|
return copyPeerDiscovery(existing), nil
|
||||||
|
}
|
||||||
|
if existing.Status == PeerDiscoveryApproved {
|
||||||
|
existing.SessionState = strings.TrimSpace(in.SessionState)
|
||||||
|
existing.ProtocolName = strings.TrimSpace(in.ProtocolName)
|
||||||
|
existing.LastSeenAt = seenAt
|
||||||
|
if sp := strings.TrimSpace(in.SpeakerID); sp != "" {
|
||||||
|
existing.SpeakerID = sp
|
||||||
|
}
|
||||||
|
return copyPeerDiscovery(existing), nil
|
||||||
|
}
|
||||||
|
existing.Neighbor = neighbor
|
||||||
|
existing.RemoteASN = in.RemoteASN
|
||||||
|
if neighborID != "" {
|
||||||
|
existing.NeighborID = neighborID
|
||||||
|
}
|
||||||
|
existing.ProtocolName = strings.TrimSpace(in.ProtocolName)
|
||||||
|
existing.SessionState = strings.TrimSpace(in.SessionState)
|
||||||
|
existing.LastSeenAt = seenAt
|
||||||
|
if sp := strings.TrimSpace(in.SpeakerID); sp != "" {
|
||||||
|
existing.SpeakerID = sp
|
||||||
|
}
|
||||||
|
return copyPeerDiscovery(existing), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
d := &BGPPeerDiscovery{
|
||||||
|
ID: id,
|
||||||
|
TenantID: tenantID,
|
||||||
|
SpeakerID: strings.TrimSpace(in.SpeakerID),
|
||||||
|
NeighborID: neighborID,
|
||||||
|
Neighbor: neighbor,
|
||||||
|
RemoteASN: in.RemoteASN,
|
||||||
|
ProtocolName: strings.TrimSpace(in.ProtocolName),
|
||||||
|
SessionState: strings.TrimSpace(in.SessionState),
|
||||||
|
Status: PeerDiscoveryPending,
|
||||||
|
FirstSeenAt: seenAt,
|
||||||
|
LastSeenAt: seenAt,
|
||||||
|
}
|
||||||
|
m.peerDiscoveries[id] = d
|
||||||
|
return copyPeerDiscovery(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) ApprovePeerDiscovery(tenantID, id string, in *PeerDiscoveryApproveInput) (*BGPPeer, *BGPPeerDiscovery, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
d, ok := m.peerDiscoveries[id]
|
||||||
|
if !ok || d.TenantID != tenantID {
|
||||||
|
return nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if d.Status != PeerDiscoveryPending {
|
||||||
|
return nil, nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
if d.RemoteASN == 0 {
|
||||||
|
return nil, nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
neighbor, okN := NormalizePeerNeighborString(d.Neighbor)
|
||||||
|
if !okN {
|
||||||
|
return nil, nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
name := ""
|
||||||
|
enabled := true
|
||||||
|
var speakerID *string
|
||||||
|
if in != nil {
|
||||||
|
name = strings.TrimSpace(in.Name)
|
||||||
|
if in.Enabled != nil {
|
||||||
|
enabled = *in.Enabled
|
||||||
|
}
|
||||||
|
if in.SpeakerID != nil {
|
||||||
|
v := strings.TrimSpace(*in.SpeakerID)
|
||||||
|
if v != "" {
|
||||||
|
speakerID = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
if d.NeighborID != "" {
|
||||||
|
name = "discovered-" + d.NeighborID
|
||||||
|
} else {
|
||||||
|
name = "discovered-" + neighbor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if speakerID == nil && strings.TrimSpace(d.SpeakerID) != "" {
|
||||||
|
sp := d.SpeakerID
|
||||||
|
speakerID = &sp
|
||||||
|
}
|
||||||
|
|
||||||
|
peerID := uuid.NewString()
|
||||||
|
peer := &BGPPeer{
|
||||||
|
ID: peerID,
|
||||||
|
TenantID: tenantID,
|
||||||
|
SpeakerID: speakerID,
|
||||||
|
Name: name,
|
||||||
|
Neighbor: neighbor,
|
||||||
|
RemoteASN: d.RemoteASN,
|
||||||
|
Enabled: enabled,
|
||||||
|
SessionState: d.SessionState,
|
||||||
|
PoliciesJSON: "{}",
|
||||||
|
}
|
||||||
|
m.peers[peerID] = peer
|
||||||
|
|
||||||
|
d.Status = PeerDiscoveryApproved
|
||||||
|
d.ApprovedPeerID = peerID
|
||||||
|
d.LastSeenAt = time.Now().UTC()
|
||||||
|
|
||||||
|
peerCopy := *peer
|
||||||
|
return &peerCopy, copyPeerDiscovery(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) RejectPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
d, ok := m.peerDiscoveries[id]
|
||||||
|
if !ok || d.TenantID != tenantID {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if d.Status == PeerDiscoveryApproved {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
d.Status = PeerDiscoveryRejected
|
||||||
|
d.LastSeenAt = time.Now().UTC()
|
||||||
|
return copyPeerDiscovery(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) findPeerDiscoveryLocked(tenantID, neighborID, neighbor string, asn int64) *BGPPeerDiscovery {
|
||||||
|
for _, d := range m.peerDiscoveries {
|
||||||
|
if d == nil || d.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if neighborID != "" && d.NeighborID == neighborID {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if neighborID == "" && d.NeighborID == "" && d.Neighbor == neighbor && d.RemoteASN == asn {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if neighborID != "" && d.NeighborID == "" && d.Neighbor == neighbor && d.RemoteASN == asn {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPeerDiscovery(d *BGPPeerDiscovery) *BGPPeerDiscovery {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *d
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PeerDiscoveryStatus values for bgp_peer_discovery.status.
|
||||||
|
const (
|
||||||
|
PeerDiscoveryPending = "pending"
|
||||||
|
PeerDiscoveryApproved = "approved"
|
||||||
|
PeerDiscoveryRejected = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BGPPeerDiscovery is a live-detected dynamic BGP session awaiting operator action.
|
||||||
|
type BGPPeerDiscovery struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
|
SpeakerID string `json:"speaker_id,omitempty"`
|
||||||
|
NeighborID string `json:"neighbor_id,omitempty"`
|
||||||
|
Neighbor string `json:"neighbor"`
|
||||||
|
RemoteASN int64 `json:"remote_asn"`
|
||||||
|
ProtocolName string `json:"protocol_name,omitempty"`
|
||||||
|
SessionState string `json:"session_state,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
ApprovedPeerID string `json:"approved_peer_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerDiscoveryUpsert is input for syncing a live dynamic session into the store.
|
||||||
|
type PeerDiscoveryUpsert struct {
|
||||||
|
SpeakerID string
|
||||||
|
NeighborID string
|
||||||
|
Neighbor string
|
||||||
|
RemoteASN int64
|
||||||
|
ProtocolName string
|
||||||
|
SessionState string
|
||||||
|
SeenAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerDiscoveryApproveInput optional fields when promoting a discovery to BGPPeer.
|
||||||
|
type PeerDiscoveryApproveInput struct {
|
||||||
|
Name string
|
||||||
|
SpeakerID *string
|
||||||
|
Enabled *bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_audit_log_tenant_action;
|
||||||
|
DROP INDEX IF EXISTS idx_audit_log_tenant_created;
|
||||||
|
DROP INDEX IF EXISTS idx_audit_log_event_id;
|
||||||
|
DROP TABLE IF EXISTS audit_log;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
source_app TEXT NOT NULL DEFAULT 'bgp',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
actor_user_id TEXT,
|
||||||
|
actor_email TEXT,
|
||||||
|
actor_name TEXT,
|
||||||
|
actor_api_key_prefix TEXT,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
details_json JSONB,
|
||||||
|
ip TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
portal_pushed_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT audit_log_severity_chk CHECK (severity IN ('info', 'warning', 'critical')),
|
||||||
|
CONSTRAINT audit_log_source_app_chk CHECK (source_app = 'bgp'),
|
||||||
|
CONSTRAINT audit_log_summary_chk CHECK (length(trim(summary)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log (event_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_created
|
||||||
|
ON audit_log (tenant_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_action
|
||||||
|
ON audit_log (tenant_id, action, created_at DESC);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn;
|
||||||
|
DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_id;
|
||||||
|
DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_status;
|
||||||
|
DROP TABLE IF EXISTS bgp_peer_discovery;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Peer auto-discovery pending / rejected / approved records.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bgp_peer_discovery (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
|
||||||
|
speaker_id UUID REFERENCES bgp_speaker (id) ON DELETE SET NULL,
|
||||||
|
neighbor_id TEXT NOT NULL DEFAULT '',
|
||||||
|
neighbor INET NOT NULL,
|
||||||
|
remote_asn BIGINT NOT NULL DEFAULT 0,
|
||||||
|
protocol_name TEXT NOT NULL DEFAULT '',
|
||||||
|
session_state TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
approved_peer_id UUID REFERENCES bgp_peer (id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT bgp_peer_discovery_status_chk CHECK (status IN ('pending', 'approved', 'rejected'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_status
|
||||||
|
ON bgp_peer_discovery (tenant_id, status);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_id
|
||||||
|
ON bgp_peer_discovery (tenant_id, neighbor_id)
|
||||||
|
WHERE neighbor_id <> '';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn
|
||||||
|
ON bgp_peer_discovery (tenant_id, neighbor, remote_asn)
|
||||||
|
WHERE neighbor_id = '';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_audit_log_tenant_action;
|
||||||
|
DROP INDEX IF EXISTS idx_audit_log_tenant_created;
|
||||||
|
DROP INDEX IF EXISTS idx_audit_log_event_id;
|
||||||
|
DROP TABLE IF EXISTS audit_log;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
source_app TEXT NOT NULL DEFAULT 'bgp',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
actor_user_id TEXT,
|
||||||
|
actor_email TEXT,
|
||||||
|
actor_name TEXT,
|
||||||
|
actor_api_key_prefix TEXT,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
details_json TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
portal_pushed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log (event_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_created
|
||||||
|
ON audit_log (tenant_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_action
|
||||||
|
ON audit_log (tenant_id, action, created_at DESC);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn;
|
||||||
|
DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_neighbor_id;
|
||||||
|
DROP INDEX IF EXISTS idx_bgp_peer_discovery_tenant_status;
|
||||||
|
DROP TABLE IF EXISTS bgp_peer_discovery;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Peer auto-discovery pending / rejected / approved records.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bgp_peer_discovery (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
|
||||||
|
speaker_id TEXT REFERENCES bgp_speaker (id) ON DELETE SET NULL,
|
||||||
|
neighbor_id TEXT NOT NULL DEFAULT '',
|
||||||
|
neighbor TEXT NOT NULL,
|
||||||
|
remote_asn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
protocol_name TEXT NOT NULL DEFAULT '',
|
||||||
|
session_state TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
approved_peer_id TEXT REFERENCES bgp_peer (id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_status
|
||||||
|
ON bgp_peer_discovery (tenant_id, status);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_id
|
||||||
|
ON bgp_peer_discovery (tenant_id, neighbor_id)
|
||||||
|
WHERE neighbor_id != '';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_bgp_peer_discovery_tenant_neighbor_asn
|
||||||
|
ON bgp_peer_discovery (tenant_id, neighbor, remote_asn)
|
||||||
|
WHERE neighbor_id = '';
|
||||||
Reference in New Issue
Block a user