Compare commits

...
23 Commits
Author SHA1 Message Date
Denozordec e51999c908 feat(firewall): add revoke functionality for firewall clients and enhance status badge
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 30s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 27s
CI / release (push) Successful in 4m19s
Implemented the ability to revoke approved firewall clients and reject pending requests through new API endpoints. Updated the StatusBadge component to include additional status variants for 'approved', 'revoked', 'pending', and 'block'. Enhanced the FirewallPage UI to support client revocation and rejection actions, integrating confirmation dialogs for user interactions. Updated tests to ensure proper functionality of the new revoke feature.
2026-07-08 23:27:29 +07:00
Denozordec b7f7669685 feat(firewall): improve blocklist parsing and nft element addition
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 42s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m17s
Enhanced the blocklist parsing function to log when the blocklist file is empty. Introduced new helper functions `nft_join_elements` and `nft_add_v4_chunk` to streamline the addition of elements to the nftables, allowing for batch processing and improved error handling. Adjusted the chunk size for element addition to optimize performance. Updated logging to provide better visibility into the blocklist processing and applied prefixes.
2026-07-08 22:02:59 +07:00
Denozordec 947d1f0cc4 feat(firewall): enhance blocklist handling and installation script
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 58s
CI / go (push) Successful in 1m20s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m37s
Updated the firewall scripts to improve blocklist handling by introducing a new method for fetching and parsing blocklist data using either `jq` or `python3`. Enhanced the installation script to ensure the presence of required dependencies and provided user guidance for post-approval actions. Additionally, improved logging for applied prefixes and total counts, ensuring better visibility into the firewall's operational status.
2026-07-08 21:45:32 +07:00
Denozordec 68f9d4b832 refactor(firewall): simplify SQL queries for firewall client retrieval
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m57s
Refactored the SQL queries in the Postgres repository for listing and retrieving firewall clients by introducing a constant for the selected columns. This change improves code readability and maintainability by reducing duplication in the query definitions. No functional changes were made to the data retrieval process.
2026-07-08 21:16:57 +07:00
Denozordec 72045afcde feat(firewall): improve error handling and documentation for firewall enrollment
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 43s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m41s
Enhanced the firewall enrollment process by implementing better error handling for HTTP responses, specifically addressing database schema issues. Updated the documentation to include migration requirements for PostgreSQL and clarified the steps to take if enrollment fails due to an outdated schema. This ensures users are better informed about necessary actions during deployment.
2026-07-08 21:00:27 +07:00
Denozordec e15768b25b feat(firewall): implement public HTTPS endpoints for firewall scripts and enhance URL handling
CI / changes (push) Successful in 15s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m39s
Added public HTTPS endpoints for firewall installation and enrollment scripts, allowing access without API keys. Updated the URL handling in the firewall code to ensure all suggested control plane URLs are served over HTTPS. Enhanced documentation to reflect the new public endpoints and their usage. Updated tests to verify the correct behavior of the new URL handling logic.
2026-07-08 18:54:27 +07:00
Denozordec 7b3f002e5f feat(firewall): enhance firewall management with community selection and script handling
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m16s
Added a community selection feature to the firewall rules management UI, allowing users to specify BGP communities for block/accept policies. Updated the backend to support reading firewall scripts from a specified directory, improving script management. Enhanced documentation to clarify the new community functionality and its implications for firewall rules. Additionally, introduced tests for the firewall script endpoints to ensure proper functionality.
2026-07-08 17:50:38 +07:00
Denozordec fa2abc81f3 feat(firewall): add install context query and API endpoint for firewall client setup
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 1m1s
CI / go (push) Successful in 1m16s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m4s
Introduced a new API endpoint for retrieving the install context of the firewall client, which includes the bundle seed, configuration status, and suggested control plane URL. Updated the frontend to utilize this new endpoint, enhancing the user experience by dynamically displaying relevant information. Additionally, added type definitions for the install context and integrated it into the existing firewall management flow.
2026-07-08 17:17:34 +07:00
Denozordec 7a3eae98b1 feat(firewall): implement firewall blocklist feature with client management and policy rules
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m59s
Introduced a comprehensive firewall blocklist feature, allowing for the management of firewall clients and their associated rules. This includes endpoints for enrolling clients, listing clients and rules, and reporting apply statuses. Enhanced the API to support firewall operations, including the ability to handle block/accept policies. Updated the documentation to reflect these changes and added necessary components in the web UI for better user interaction.

Additionally, modified the agent server to support firewall failover and integrated firewall functionality into the existing architecture.
2026-07-08 16:37:27 +07:00
Denozordec 276194a9d0 fix(daemon): update PID and timestamps in .codegraph/daemon.pid for synchronization 2026-07-07 17:09:55 +07:00
Denozordec 53b3c49612 refactor(settings): simplify settings query options and remove tenant dependency
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m5s
Updated the settings query options to eliminate the tenant ID parameter, streamlining the settings retrieval process. Adjusted the TenantSettingsComponent to reflect this change, ensuring it now queries settings without relying on tenant-specific data. This refactor enhances code clarity and reduces complexity in the settings management flow.
2026-07-06 23:28:14 +07:00
Denozordec db79820df0 feat(auth): introduce demo token support and enhance API token handling
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m57s
Added a local demo token for development purposes and improved the API token management by normalizing input tokens. Updated the authentication flow to utilize the new token handling, allowing for better session management and user experience. Enhanced the settings component to support the demo token and provide clear instructions for its use in local development.
2026-07-06 22:55:30 +07:00
Denozordec a902a4270d fix(api-keys): enhance API key management with new mutations and UI updates
CI / changes (push) Successful in 14s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 59s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m47s
Added new mutations for creating, revoking, and rotating API keys in the api-keys query file. Updated the Access component to utilize these mutations, improving the user interface with better feedback and session management. Introduced a new AccessApiKeysCard for displaying API key information and enhanced the overall layout and user experience in the access route.
2026-07-06 20:32:17 +07:00
Denozordec 3a3e6db018 fix(tabs): update Tabs component for orientation handling and improve class names
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 45s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m15s
Enhanced the Tabs component to support orientation prop, ensuring proper layout for both horizontal and vertical orientations. Updated class names for better clarity and consistency in styling. Additionally, modified the daemon PID and timestamps in .codegraph/daemon.pid for synchronization.
2026-07-06 18:45:44 +07:00
Denozordec 144d342c16 fix(modules): enhance ModuleDetailComponent with additional queries and UI updates
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m3s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m4s
Updated the ModuleDetailComponent to include new queries for communities and DoH profiles, improving data handling. Added a module type alert function for better user guidance and refined the UI to display module type in a more user-friendly manner. Removed unused components and streamlined the refresh functionality for better performance.
2026-07-03 16:50:21 +07:00
Denozordec 0af37d55c4 fix(pagination): enhance DataGridPagination to dynamically populate Select items
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m0s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m37s
Updated the DataGridPagination component to map available sizes into Select items for better user experience. This change allows for dynamic selection of page sizes based on the provided props.
2026-07-03 14:43:46 +07:00
Denozordec 78f2ecc246 fix(auth): update Select components to include SelectValue for better placeholder handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m0s
Modified the Select components in operations.tsx, settings.tsx, and tenant-settings.tsx to incorporate SelectValue for improved placeholder functionality. Additionally, updated the daemon PID and timestamps in .codegraph/daemon.pid for consistency.
2026-07-03 14:10:23 +07:00
DenozordecandCursor 8c97445f7e fix(jobs): make deploy coalescing deterministic via inflight counter
CI / changes (push) Successful in 12s
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m27s
TestParallelModuleRefresh_CoalescesDeployApply падал на CI под -race
(want exactly one deploy_apply job, got 2). Локально тест проходил
стабильно (100/500 итераций с -cpu), но узкая гонка проявлялась при
замедлении под race-детектором.

Корень: коалесцирование решало «делать ли deploy» через
CountOtherActiveRefresh, который опрашивал статусы job-ов (queued/
running). Статусы меняются асинхронно относительно tenantRefreshMu,
поэтому в редких таймингах оба параллельных refresh могли решить,
что другой уже не активен, и каждый породил свой deploy_apply.

Решение — детерминированный inflight-счётчик refresh-kind job-ов в
Registry (inflightRefresh map[string]int), управляемый под r.mu:
- инкремент в Enqueue при создании нового refresh-kind job-а;
- декремент + проверка «последний ли я» в finishModuleRefreshSuccess
  через новый метод finalizeRefreshCoalesce (под tenantRefreshMu).

Последний refresh (счётчик <= 1) делает render + deploy_apply; все
остальные defer-ят. Решение больше не зависит от опроса статусов и
таймингов ingest.

Чтобы счётчик не утёк на error/cancel путях (где refresh не доходит
до finishModuleRefreshSuccess), обработка module_refresh и
tenant_refresh вынесена в runModuleRefresh / runTenantRefresh с
defer-обёрткой, которая гарантированно освобождает слот, если
finishModuleRefreshSuccess не отработал.

CountOtherActiveRefresh / CountOtherActiveModuleRefresh оставлены
как публичные методы (могут использоваться в мониторинге); из
продакшн-логики коалесцирования убраны.

Проверки: go build, go vet, go test ./internal/... -count=1 — exit 0.
Стресс-тест коалесцирования: 200 итераций с -cpu=4 — стабильно.

Co-authored-by: Cursor <[email protected]>
2026-07-03 01:31:32 +07:00
DenozordecandCursor 0ea5b3b738 fix(bird2): switch download host from bird.network.cz to bird.nic.cz
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Docker-сборка birdc падала с HTTP 403 при скачивании исходников BIRD
2.14 с bird.network.cz. Старый домен bird.network.cz больше не отдаёт
файлы (403 для всех путей и User-Agent), официальный сайт BIRD переехал
на bird.nic.cz.

URL bird.nic.cz/download/bird-2.14.tar.gz проверен: 200 OK, ~1.4 МБ,
совпадает с размером на старом домене. Страница old-releases на новом
домене подтверждает тот же путь /download/bird-2.14.tar.gz.

Изменены все 5 вхождений старого домена в репозитории:
- deploy/docker/bird/bird-from-source.sh — критичный curl в сборке;
- .cursor/rules/networking-bird.mdc — BIRD2 docs и DOC-SYNC-05;
- .cursor/rules/context7-stack.mdc — приоритет источников;
- .cursor/rules/engineering.mdc — таблица Documentation Sync.

Co-authored-by: Cursor <[email protected]>
2026-07-02 23:30:56 +07:00
DenozordecandCursor 66b785f7cb fix(ci): replace pnpm/action-setup with corepack for web job
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 1m3s
CI / go (push) Successful in 1m10s
CI / bird2 (push) Successful in 19s
CI / release (push) Failing after 3m33s
pnpm/action-setup@v4 на Gitea Actions продолжает падать на
Running self-installer даже после перестановки шагов
(setup-node перед action-setup). Action использует GitHub-specific
механику скачивания pnpm, которая ненадёжна на Gitea runner.

Вместо pnpm/action-setup используем corepack (встроен в Node 22):
corepack enable активирует [email protected].2 из поля packageManager в
package.json — тот же механизм, что работает в Dockerfile. Это
устраняет зависимость от внешнего action и его self-installer.

Кэш pnpm-store добавим позже через actions/cache или setup-node
cache: pnpm (теперь pnpm доступен к моменту настройки кэша).
Сейчас приоритет — сделать step рабочим.

Co-authored-by: Cursor <[email protected]>
2026-07-02 23:17:26 +07:00
DenozordecandCursor cb14194a5f fix(ci): order setup-node before pnpm action and cache pnpm store
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Failing after 9s
CI / go (push) Successful in 58s
CI / bird2 (push) Successful in 15s
CI / release (push) Has been skipped
Job web падал на шаге pnpm/action-setup@v4 (Running self-installer)
из-за неверного порядка: pnpm/action-setup вызывался до
actions/setup-node, поэтому Node.js ещё не был настроен к моменту
запуска self-installer pnpm. Добавление packageManager в package.json
усугубило ситуацию — action начал honour'ить его через node-зависимый
инсталлер, которому не хватило Node.

Изменения в job web:
- actions/setup-node@v4 (node 22) перенесён ВЫШЕ pnpm/action-setup@v4;
- кэш pnpm-store включён через cache: true в pnpm/action-setup
  (вместо cache: pnpm в setup-node, который требует pnpm установленным
  раньше и спотыкается о тот же порядок).

Версия pnpm задаётся полем packageManager в package.json ([email protected].2)
и дублируется в Dockerfile через corepack prepare, поэтому version: 10
в action оставлен как совместимый fallback.

Co-authored-by: Cursor <[email protected]>
2026-07-02 23:09:44 +07:00
Denozordec 9c38e1bc57 fix(ci): update daemon PID and timestamps in configuration files
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Failing after 9s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Has been skipped
Updated the daemon PID from 44608 to 52448 and adjusted the startedAt timestamp in the .codegraph/daemon.pid file. Additionally, modified the CI workflow configuration in .gitea/workflows/ci.yaml to ensure consistent pnpm version usage during the setup process.
2026-07-02 19:10:18 +07:00
DenozordecandCursor 4ac99e43ae fix(web): pin pnpm version via packageManager and corepack prepare
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 24s
CI / go (push) Successful in 53s
CI / bird2 (push) Successful in 16s
CI / web (push) Failing after 7s
CI / release (push) Has been skipped
Предыдущий фикс (onlyBuiltDependencies в pnpm-workspace.yaml) оказался
недостаточным: Docker-сборка evobgp-web продолжала падать с
ERR_PNPM_IGNORED_BUILDS для esbuild. Причина — в корневом package.json
отсутствовало поле packageManager, а Dockerfile вызывал corepack enable
без пина версии. Corepack активировал устаревшую pnpm, зашитую в образ
node:22-alpine, которая не поддерживает чтение onlyBuiltDependencies из
pnpm-workspace.yaml (эта возможность появилась в pnpm 10.4+).

Двойная защита от рассинхрона версий pnpm между локальной средой,
CI и Docker:

- package.json: packageManager = [email protected].2 (стандарт corepack).
- Dockerfile: corepack prepare [email protected].2 --activate — явно ставит
  нужную версию даже если package.json ещё не скопирован на момент
  первого вызова pnpm.

Локально: typecheck и build @evobgp/web — exit 0.

Co-authored-by: Cursor <[email protected]>
2026-07-02 19:03:58 +07:00
86 changed files with 8200 additions and 441 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 44608,
"pid": 39884,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1781240018712
"startedAt": 1783489774882
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -82,7 +82,7 @@ UI-правила репозитория: `.cursor/rules/web-shadcn.mdc` (MCP +
1. **Контракт HTTP** — `docs/openapi.yaml` (не Context7).
2. **Context7** — синтаксис и API библиотек из таблицы.
3. **Локальные docs** — `docs/`, `web/README.md`, `AGENTS.md`.
4. **Официальный сайт** — BIRD: https://bird.network.cz/?get_doc (если Context7 не покрыл кейс).
4. **Официальный сайт** — BIRD: https://bird.nic.cz/?get_doc (если Context7 не покрыл кейс).
## Примеры запросов
+1 -1
View File
@@ -207,7 +207,7 @@ alwaysApply: true
| OpenAPI / problem+json | `docs/openapi.yaml`, RFC 9457 |
| Svelte / Kit | https://svelte.dev/docs , https://kit.svelte.dev/docs |
| shadcn-svelte | https://shadcn-svelte.com/docs |
| BIRD 2 | https://bird.network.cz/?get_doc |
| BIRD 2 | https://bird.nic.cz/?get_doc |
| Prometheus Go | https://pkg.go.dev/github.com/prometheus/client_golang |
**DOC-SYNC-01** | MUST | Новый API библиотеки — сверка версии в `go.mod`/`package.json` с официальной документацией.
+2 -2
View File
@@ -23,7 +23,7 @@ alwaysApply: false
| Параметры BIRD tenant | Global settings: `bird_router_id`, `bird_local_asn`, … (`docs/manual.md`) |
| BGP peers | `BGPPeer` + `ParsePeerNeighbor` |
**BIRD2 docs:** https://bird.network.cz/?get_doc
**BIRD2 docs:** https://bird.nic.cz/?get_doc
---
@@ -199,7 +199,7 @@ alwaysApply: false
## Documentation Sync
**DOC-SYNC-05** | MUST | BIRD — https://bird.network.cz/?get_doc
**DOC-SYNC-05** | MUST | BIRD — https://bird.nic.cz/?get_doc
**DOC-SYNC-08** | MUST | BGP policy — RFC 4271, 4760, 7454 + BIRD docs + `birdfmt`
**DOC-SYNC-09** | MUST | CIDR — https://pkg.go.dev/net/netip ; примеры — RFC 5737, 3849
+2 -5
View File
@@ -167,14 +167,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Enable pnpm via corepack
run: corepack enable
- name: pnpm install, typecheck, lint, build
run: |
set -euxo pipefail
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { Badge } from '@/components/reui/badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge'
import { TableSkeleton } from '@/components/skeletons'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKey, ApiKeyCreated } from '@/types/api'
interface AccessApiKeysCardProps {
items: ApiKey[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function AccessApiKeysCard({
items,
isLoading,
isError,
error,
onRetry,
}: AccessApiKeysCardProps) {
const [createOpen, setCreateOpen] = useState(false)
const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
const [revealedToken, setRevealedToken] = useState('')
const revoke = useRevokeApiKeyMutation()
const rotate = useRotateApiKeyMutation()
function showToken(created: ApiKeyCreated) {
setRevealedToken(created.token)
setTokenDialogOpen(true)
}
function handleRotated(id: string) {
rotate.mutate(id, {
onSuccess: (created) => showToken(created),
})
}
return (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
skeleton={<TableSkeleton rows={4} cols={6} />}
onRetry={onRetry}
>
{(data) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Префикс</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Истекает</TableHead>
<TableHead>Последнее использование</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{k.role}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{k.prefix}
</TableCell>
<TableCell>
{k.revoked_at ? (
<StatusBadge status="error" label="отозван" />
) : (
<StatusBadge status="active" label="активен" />
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.expires_at)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatApiKeyDate(k.last_used_at)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at || rotate.isPending}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => handleRotated(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={!!k.revoked_at || revoke.isPending}
title="Отозвать"
>
<Trash2 className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => revoke.mutate(k.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<ApiKeyCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={showToken}
/>
<ApiKeyTokenDialog
open={tokenDialogOpen}
token={revealedToken}
onOpenChange={setTokenDialogOpen}
/>
</>
)
}
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { LoadingButton } from '@/components/loading-button'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
interface ApiKeyCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (created: ApiKeyCreated) => void
}
export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) {
const createMutation = useCreateApiKeyMutation()
const [name, setName] = useState('')
const [role, setRole] = useState<ApiKeyRole>('editor')
const [expiresLocal, setExpiresLocal] = useState('')
useEffect(() => {
if (!open) return
setName('')
setRole('editor')
setExpiresLocal('')
}, [open])
function handleOpenChange(next: boolean) {
onOpenChange(next)
}
async function save() {
if (!name.trim()) {
toast.error('Укажите имя')
return
}
const body: ApiKeyCreate = {
name: name.trim(),
role,
}
if (expiresLocal.trim()) {
const d = new Date(expiresLocal)
if (Number.isNaN(d.getTime())) {
toast.error('Некорректная дата истечения')
return
}
body.expires_at = d.toISOString()
}
try {
const created = await createMutation.mutateAsync(body)
onOpenChange(false)
onCreated(created)
} catch {
// toast handled in mutation
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-role">Роль</Label>
<Select
items={[...API_KEY_ROLE_ITEMS]}
value={role}
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
>
<SelectTrigger id="key-role" className="w-full">
<SelectValue placeholder="Выберите роль" />
</SelectTrigger>
<SelectContent>
{API_KEY_ROLE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена
</Button>
<LoadingButton onClick={save} loading={createMutation.isPending}>
Создать
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,51 @@
import { Copy } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
interface ApiKeyTokenDialogProps {
open: boolean
token: string
onOpenChange: (open: boolean) => void
}
export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) {
async function copyToken() {
if (!token) return
try {
await navigator.clipboard.writeText(token)
toast.success('Скопировано')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription>
Он больше не будет показан. Скопируйте в безопасное хранилище.
</DialogDescription>
</DialogHeader>
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
<DialogFooter>
<Button variant="outline" onClick={copyToken}>
<Copy />
Копировать
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -9,6 +9,7 @@ import {
BookText,
KeyRound,
ServerCog,
Shield,
} from 'lucide-react'
import {
@@ -69,6 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog },
{ to: '/firewall', label: 'Firewall', icon: Shield },
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
],
@@ -0,0 +1,76 @@
import { useMemo } from 'react'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import {
NONE_OPTION,
communityOptionLabel,
fromNullableSelect,
nullableSelectValue,
} from '@/lib/modules/helpers'
import type { BgpCommunity } from '@/types/api'
interface CommunitySelectProps {
id?: string
label?: string
value: string | null
onValueChange: (value: string | null) => void
communities: BgpCommunity[]
nullable?: boolean
placeholder?: string
}
export function CommunitySelect({
id,
label,
value,
onValueChange,
communities,
nullable = false,
placeholder = 'Выберите community',
}: CommunitySelectProps) {
const items = useMemo(() => {
const communityItems = communities.map((c) => ({
value: c.id,
label: communityOptionLabel(c),
}))
if (nullable) {
return [{ value: NONE_OPTION, label: 'Не выбрано' }, ...communityItems]
}
return communityItems
}, [communities, nullable])
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
return (
<div className="flex flex-col gap-1.5">
{label ? <Label htmlFor={id}>{label}</Label> : null}
<Select
items={items}
value={selectValue}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '@/types/api'
interface ModuleAsEntryDialogProps {
open: boolean
moduleId: string
edit: AsEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleAsEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleAsEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<AsEntryCreate>({ asn: 0, community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null },
)
}, [open, edit])
async function save() {
const asn = Number(form.asn)
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
toast.error('Укажите корректный ASN (14294967295)')
return
}
setSaving(true)
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body)
toast.success('Запись обновлена')
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate)
toast.success('Запись добавлена')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' },
{ value: 'json', label: 'json' },
] as const
interface ModuleCdnSourceDialogProps {
open: boolean
moduleId: string
edit: CdnSource | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
export function ModuleCdnSourceDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleCdnSourceDialogProps) {
const [saving, setSaving] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewItems, setPreviewItems] = useState<string[]>([])
const [previewTotal, setPreviewTotal] = useState(0)
const [previewTruncated, setPreviewTruncated] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewOk, setPreviewOk] = useState(false)
const [form, setForm] = useState<CdnForm>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null,
})
const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
function clearPreview() {
setPreviewLoading(false)
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewError(null)
setPreviewOk(false)
}
useEffect(() => {
if (!open) {
clearPreview()
return
}
setForm(
edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec,
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
)
clearPreview()
}, [open, edit])
async function previewCdn() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setPreviewLoading(true)
setPreviewError(null)
setPreviewOk(false)
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
},
)
setPreviewItems(res.items)
setPreviewTotal(res.total)
setPreviewTruncated(res.truncated)
setPreviewOk(true)
} catch (e) {
setPreviewError(e instanceof ApiError ? e.message : String(e))
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewOk(false)
} finally {
setPreviewLoading(false)
}
}
async function save() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setSaving(true)
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
}
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
toast.success('Источник обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
toast.success('Источник добавлен')
}
clearPreview()
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-kind">Тип источника</Label>
<Select
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
>
<SelectTrigger id="cdn-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{kindItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '@/types/api'
interface ModuleDomainEntryDialogProps {
open: boolean
moduleId: string
edit: DomainEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleDomainEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleDomainEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<DomainEntryCreate>({ fqdn: '', community_id: null })
useEffect(() => {
if (!open) return
setForm(
edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null },
)
}, [open, edit])
async function save() {
if (!form.fqdn.trim()) {
toast.error('Укажите FQDN')
return
}
setSaving(true)
try {
const body = { fqdn: form.fqdn.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', body)
toast.success('Домен обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', body)
toast.success('Домен добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,437 @@
import { useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
import { ApiError, apiMutate } from '@/lib/api-client'
import { communityLabel } from '@/lib/modules/helpers'
import { formatDateTime } from '@/lib/modules/display'
import type {
AsEntry,
BgpCommunity,
CdnSource,
DomainEntry,
IpRangeEntry,
ModuleRow,
} from '@/types/api'
interface ModuleEntriesSectionProps {
moduleId: string
mod: ModuleRow
items: Record<string, unknown>[]
communities: BgpCommunity[]
isLoading: boolean
isError: boolean
error: Error | null
onRetry: () => void
onChanged: () => void | Promise<void>
}
type DeleteTarget =
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
const CARD_META: Record<
ModuleRow['type'],
{ title: string; description: string; emptyTitle: string; emptyDescription: string }
> = {
DOMAINS: {
title: 'Домены',
description: 'FQDN для резолвинга через DoH.',
emptyTitle: 'Нет доменов',
emptyDescription: 'Добавьте FQDN для резолвинга.',
},
IP_RANGES: {
title: 'IP-диапазоны',
description: 'Статические CIDR для анонса.',
emptyTitle: 'Нет диапазонов',
emptyDescription: 'Добавьте CIDR.',
},
CDN_CIDRS: {
title: 'CDN-источники',
description: 'URL источников для скачивания списков CIDR.',
emptyTitle: 'Нет источников',
emptyDescription: 'Добавьте CDN-источник.',
},
AS_PREFIXES: {
title: 'AS-записи',
description: 'ASN для получения префиксов через RIPEstat.',
emptyTitle: 'Нет записей',
emptyDescription: 'Добавьте ASN.',
},
}
export function ModuleEntriesSection({
moduleId,
mod,
items,
communities,
isLoading,
isError,
error,
onRetry,
onChanged,
}: ModuleEntriesSectionProps) {
const meta = CARD_META[mod.type]
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null)
const [deleting, setDeleting] = useState(false)
const [editDomain, setEditDomain] = useState<DomainEntry | null>(null)
const [editIpRange, setEditIpRange] = useState<IpRangeEntry | null>(null)
const [editCdn, setEditCdn] = useState<CdnSource | null>(null)
const [editAs, setEditAs] = useState<AsEntry | null>(null)
function openCreate() {
setEditDomain(null)
setEditIpRange(null)
setEditCdn(null)
setEditAs(null)
setDialogOpen(true)
}
function closeDialog() {
setDialogOpen(false)
setEditDomain(null)
setEditIpRange(null)
setEditCdn(null)
setEditAs(null)
}
async function confirmDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
const { kind, entry } = deleteTarget
const pathByKind = {
domain: `/v1/modules/${moduleId}/domain-entries/${entry.id}`,
'ip-range': `/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
cdn: `/v1/modules/${moduleId}/cdn-sources/${entry.id}`,
as: `/v1/modules/${moduleId}/as-entries/${entry.id}`,
} as const
await apiMutate(pathByKind[kind], 'DELETE', undefined, { idempotent: false })
toast.success('Удалено')
setDeleteTarget(null)
await onChanged()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setDeleting(false)
}
}
return (
<>
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">{meta.title}</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onClick={openCreate}>
<Plus />
Добавить
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle={meta.emptyTitle}
emptyDescription={meta.emptyDescription}
skeleton={<TableSkeleton rows={5} cols={3} />}
onRetry={onRetry}
>
{(rows) => (
<EntriesTable
mod={mod}
rows={rows}
communities={communities}
onEdit={(target) => {
if (target.kind === 'domain') setEditDomain(target.entry)
if (target.kind === 'ip-range') setEditIpRange(target.entry)
if (target.kind === 'cdn') setEditCdn(target.entry)
if (target.kind === 'as') setEditAs(target.entry)
setDialogOpen(true)
}}
onDelete={setDeleteTarget}
/>
)}
</QueryState>
</CardContent>
</Card>
{mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editDomain}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'IP_RANGES' ? (
<ModuleIpRangeEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editIpRange}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'CDN_CIDRS' ? (
<ModuleCdnSourceDialog
open={dialogOpen}
moduleId={moduleId}
edit={editCdn}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
{mod.type === 'AS_PREFIXES' ? (
<ModuleAsEntryDialog
open={dialogOpen}
moduleId={moduleId}
edit={editAs}
communities={communities}
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
onSaved={onChanged}
/>
) : null}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => void confirmDelete()}
>
{deleting ? 'Удаление…' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function deleteDescription(target: DeleteTarget | null): string {
if (!target) return ''
switch (target.kind) {
case 'domain':
return target.entry.fqdn
case 'ip-range':
return target.entry.prefix
case 'cdn':
return target.entry.url
case 'as':
return `AS${target.entry.asn}`
}
}
function EntriesTable({
mod,
rows,
communities,
onEdit,
onDelete,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
}) {
if (mod.type === 'DOMAINS') {
const entries = rows as unknown as DomainEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>FQDN</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry })}
onDelete={() => onDelete({ kind: 'domain', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'IP_RANGES') {
const entries = rows as unknown as IpRangeEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Префикс (CIDR)</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry })}
onDelete={() => onDelete({ kind: 'ip-range', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'CDN_CIDRS') {
const entries = rows as unknown as CdnSource[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Community</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
<TableCell className="text-sm">{entry.source_kind}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(entry.last_refreshed_at)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry })}
onDelete={() => onDelete({ kind: 'cdn', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
const entries = rows as unknown as AsEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ASN</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'as', entry })}
onDelete={() => onDelete({ kind: 'as', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '@/types/api'
interface ModuleIpRangeEntryDialogProps {
open: boolean
moduleId: string
edit: IpRangeEntry | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
export function ModuleIpRangeEntryDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleIpRangeEntryDialogProps) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<IpRangeEntryCreate>({ prefix: '', community_id: '' })
useEffect(() => {
if (!open) return
setForm(
edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' },
)
}, [open, edit])
async function save() {
if (!form.prefix.trim() || !form.community_id) {
toast.error('Укажите префикс и community')
return
}
setSaving(true)
try {
const body = { prefix: form.prefix.trim(), community_id: form.community_id }
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', body)
toast.success('Диапазон обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', body)
toast.success('Диапазон добавлен')
}
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,84 @@
import {
ArrowDownUp,
Network,
RefreshCw,
ShieldCheck,
Timer,
} from 'lucide-react'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
interface ModuleKpiCardsProps {
mod: ModuleRow | null
communities: BgpCommunity[]
dohProfiles: DohProfile[]
asEntries: AsEntry[]
loading?: boolean
}
export function ModuleKpiCards({
mod,
communities,
dohProfiles,
asEntries,
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
return <SectionCardsSkeleton count={5} />
}
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const items: SectionCardItem[] = [
{
label: 'Приоритет',
value: String(mod.priority ?? 0),
hint: 'порядок в сборке ревизии',
icon: <ArrowDownUp className="size-3.5" />,
},
{
label: 'Интервал',
value: moduleIntervalLabel(mod),
hint: 'refresh_interval_sec / cron',
icon: <Timer className="size-3.5" />,
},
{
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
hint:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: <Network className="size-3.5" />,
},
{
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
hint: 'для записей без своего community',
icon: <ShieldCheck className="size-3.5" />,
},
{
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
hint:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: <RefreshCw className="size-3.5" />,
},
]
return <SectionCards items={items} />
}
@@ -153,6 +153,10 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
{mergedProps.rowsPerPageLabel}
</div>
<Select
items={mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
}))}
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
+5
View File
@@ -22,6 +22,11 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
stale: 'warning',
warning: 'warning',
mismatch: 'warning',
pending: 'warning',
approved: 'success',
revoked: 'destructive',
block: 'destructive',
accept: 'success',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
+19
View File
@@ -0,0 +1,19 @@
import type { ApiKeyRole } from '@/types/api'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' },
]
export function apiKeyRoleLabel(role: ApiKeyRole): string {
return API_KEY_ROLE_ITEMS.find((o) => o.value === role)?.label ?? role
}
export function formatApiKeyDate(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString('ru-RU')
}
+22 -2
View File
@@ -11,6 +11,9 @@ import type {
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
export const DEV_API_TOKEN = 'dev'
export type Problem = {
type?: string
title?: string
@@ -18,14 +21,31 @@ export type Problem = {
detail?: string
}
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
export function normalizeApiToken(raw: string): string {
let t = raw.trim()
if (/^bearer\s+/i.test(t)) {
t = t.replace(/^bearer\s+/i, '').trim()
}
return t
}
function getToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
if (!raw) return null
const normalized = normalizeApiToken(raw)
return normalized || null
}
export function setToken(token: string | null): void {
if (typeof window === 'undefined') return
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
if (!token) {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
return
}
const normalized = normalizeApiToken(token)
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
}
+24
View File
@@ -0,0 +1,24 @@
import type { ModuleRow } from '@/types/api'
export function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return '—'
return parsed.toLocaleString('ru-RU')
}
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : ''
const raw = moduleRow.refresh_interval_sec as unknown
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : ''
if (cron && intervalLabel) return `${intervalLabel} (${cron})`
if (cron) return cron
if (intervalLabel) return intervalLabel
return '—'
}
+41
View File
@@ -0,0 +1,41 @@
import type { BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
export const NONE_OPTION = '__none__'
export function communityLabel(id: string | null | undefined, communities: BgpCommunity[]): string {
if (!id) return '—'
const c = communities.find((x) => x.id === id)
if (!c) return `${id.slice(0, 8)}`
const t = c.title?.trim()
return t || c.community
}
export function communityOptionLabel(c: BgpCommunity): string {
const t = c.title?.trim()
return t || c.community
}
export function nullableSelectValue(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return NONE_OPTION
return value
}
export function fromNullableSelect(value: string): string | null {
if (value === NONE_OPTION || value === '') return null
return value
}
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
if (!modRow) return []
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids
return modRow.doh_profile_id ? [modRow.doh_profile_id] : []
}
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id)
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}`
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
}
+29
View File
@@ -0,0 +1,29 @@
import type { DohResolverPolicy } from '@/types/api'
export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefined): string {
switch (policy) {
case 'primary_only':
return 'Только первый'
case 'failover':
return 'Резервирование'
case 'union':
return 'Объединение'
default:
return 'Только первый'
}
}
export function moduleTypeRu(type: string): string {
switch (type) {
case 'AS_PREFIXES':
return 'AS (номера)'
case 'CDN_CIDRS':
return 'CDN CIDR'
case 'DOMAINS':
return 'Домены'
case 'IP_RANGES':
return 'IP-диапазоны'
default:
return type
}
}
+50 -3
View File
@@ -1,6 +1,8 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { ApiKey, ApiKeysResponse } from '@/types/api'
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeysResponse } from '@/types/api'
export const apiKeysKeys = {
all: ['api-keys'] as const,
@@ -17,3 +19,48 @@ export function apiKeysQueryOptions() {
staleTime: 60_000,
})
}
function invalidateApiKeysList(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: apiKeysKeys.list() })
}
export function useCreateApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ApiKeyCreate) =>
apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Ключ создан')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать ключ'),
})
}
export function useRevokeApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Ключ отозван')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
})
}
export function useRotateApiKeyMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
idempotent: false,
}),
onSuccess: () => {
toast.success('Ключ ротирован')
invalidateApiKeysList(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
})
}
+100
View File
@@ -0,0 +1,100 @@
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 useRevokeFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<{ status: string }>(`/v1/firewall/clients/${id}/revoke`, { method: 'POST' }),
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: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
})
}
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 -4
View File
@@ -1,10 +1,14 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
export const Route = createFileRoute('/_auth')({
beforeLoad: () => {
const token =
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
if (!token) {
beforeLoad: ({ location }) => {
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
if (location.pathname === '/settings') return
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings' })
}
},
+102 -190
View File
@@ -1,29 +1,18 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { authSessionQueryOptions } from '@/queries/auth'
import { apiKeysQueryOptions } from '@/queries/api-keys'
import { apiMutate } from '@/lib/api-client'
import { useMutation } from '@tanstack/react-query'
import type { ApiKeyCreated } from '@/types/api'
import { useState } from 'react'
import { Copy } from 'lucide-react'
export const Route = createFileRoute('/_auth/access')({
component: AccessComponent,
@@ -39,20 +28,80 @@ function AccessComponent() {
enabled: isOperator,
})
const keys = keysQuery.data ?? []
const activeCount = keys.filter((k) => !k.revoked_at).length
const revokedCount = keys.filter((k) => k.revoked_at).length
const refreshing = sessionQuery.isFetching || keysQuery.isFetching
const kpiItems: SectionCardItem[] = useMemo(
() => [
{
label: 'Всего ключей',
value: keys.length,
icon: <KeyRound className="size-4" />,
hint: 'в tenant',
},
{
label: 'Активных',
value: activeCount,
icon: <ShieldCheck className="size-4" />,
hint: 'не отозваны',
},
{
label: 'Отозванных',
value: revokedCount,
icon: <ShieldOff className="size-4" />,
hint: 'revoked',
variant: revokedCount > 0 ? 'warning' : 'default',
},
],
[keys.length, activeCount, revokedCount],
)
function refetchAll() {
void sessionQuery.refetch()
if (isOperator) void keysQuery.refetch()
}
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6">
<div className="flex flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
actions={
isOperator ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
) : undefined
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О API-ключах</AlertTitle>
<AlertDescription>
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
показывается один раз при создании и ротации. Токен браузера в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
(роль operator).
</AlertDescription>
</Alert>
{session ? (
<Card>
<CardHeader>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Текущая сессия</CardTitle>
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
<CardContent className="grid gap-3 p-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>
@@ -63,183 +112,46 @@ function AccessComponent() {
</div>
</CardContent>
</Card>
) : null}
) : (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
</CardContent>
</Card>
)}
{isOperator ? (
<ApiKeysCard
items={keysQuery.data ?? []}
isLoading={keysQuery.isLoading}
isError={keysQuery.isError}
error={keysQuery.error}
onRetry={() => keysQuery.refetch()}
/>
<>
{keysQuery.isLoading ? (
<SectionCardsSkeleton count={3} />
) : (
<SectionCards items={kpiItems} />
)}
<AccessApiKeysCard
items={keys}
isLoading={keysQuery.isLoading}
isError={keysQuery.isError}
error={keysQuery.error}
onRetry={() => keysQuery.refetch()}
/>
</>
) : session ? (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
<span className="font-mono">{session.role}</span>.
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
operator-ключом или создайте ключ через API / переменную{' '}
<code className="text-xs">EVOBGP_API_KEYS</code>.
</CardContent>
</Card>
) : null}
</div>
)
}
function ApiKeysCard({
items,
isLoading,
isError,
error,
onRetry,
}: {
items: import('@/types/api').ApiKey[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}) {
const [revealedToken, setRevealedToken] = useState<string | null>(null)
const revoke = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => toast.success('Ключ отозван'),
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
})
const rotate = useMutation({
mutationFn: (id: string) =>
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
idempotent: false,
}),
onSuccess: (created) => {
toast.success('Ключ ротирован')
setRevealedToken(created.token)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
})
async function copyToken() {
if (!revealedToken) return
try {
await navigator.clipboard.writeText(revealedToken)
toast.success('Скопировано')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Ключи можно создать через API."
onRetry={onRetry}
>
{(data) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Префикс</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell className="font-mono text-sm">{k.role}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}</TableCell>
<TableCell>
{k.revoked_at ? (
<span className="text-sm text-destructive">отозван</span>
) : (
<span className="text-sm text-muted-foreground">активен</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => rotate.mutate(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={!!k.revoked_at}
title="Отозвать"
>
<Copy className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => revoke.mutate(k.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
{revealedToken ? (
<div className="flex flex-col gap-3 border-t p-4">
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
{revealedToken}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyToken}>
<Copy /> Копировать
</Button>
<Button size="sm" onClick={() => setRevealedToken(null)}>
Готово
</Button>
</div>
</div>
) : null}
</Card>
)
}
+424
View File
@@ -0,0 +1,424 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select'
import { StatusBadge } from '@/components/status-badge'
import { communityLabel } from '@/lib/modules/helpers'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import {
firewallClientsQueryOptions,
firewallInstallContextQueryOptions,
firewallRulesQueryOptions,
useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallRule,
useRevokeFirewallClient,
} from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
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 revoke = useRevokeFirewallClient()
const createRule = useCreateFirewallRule()
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('')
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 [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
const [ruleCommunityId, setRuleCommunityId] = useState<string | null>(null)
const [ruleComment, setRuleComment] = useState('')
const communities = communitiesQ.data?.items ?? []
const clients = clientsQ.data?.items ?? []
const pending = clients.filter((c) => c.status === 'pending')
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'
: 'Bundle seed недоступен (нужна роль operator)',
)
return
}
try {
await navigator.clipboard.writeText(installCmd)
toast.success('Команда скопирована')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Firewall 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>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Политика</AlertTitle>
<AlertDescription>
Правила сопоставляются с <strong>BGP community</strong> префиксов опубликованной revision.{' '}
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> не блокирует.
Community «Все» правило для любого community. Default без совпадений accept.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" />
Установка на сервер
</CardTitle>
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll approve в «Запросы».</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-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">Bundle 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
? 'Загрузка из control plane…'
: installCtx?.bundle_seed_configured
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'}
</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>
</CardContent>
</Card>
<Tabs defaultValue="clients">
<TabsList>
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger>
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-4">
<ClientsTable
clients={clients}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => revoke.mutate(id)}
approvePending={approve.isPending}
rejectPending={revoke.isPending}
/>
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label>Действие</Label>
<select
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
value={ruleAction}
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')}
>
<option value="block">block</option>
<option value="accept">accept</option>
</select>
</div>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={ruleCommunityId}
onValueChange={setRuleCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="space-y-1">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
className="max-w-xs"
placeholder="Комментарий"
value={ruleComment}
onChange={(e) => setRuleComment(e.target.value)}
/>
</div>
<Button
size="sm"
className="mb-0.5"
onClick={() =>
createRule.mutate({
scope: 'tenant',
action: ruleAction,
community_id: ruleCommunityId,
comment: ruleComment,
})
}
>
Добавить правило
</Button>
</div>
<RulesTable
rules={rules}
communities={communities}
onDelete={(id) => deleteRule.mutate(id)}
/>
</TabsContent>
<TabsContent value="requests" className="mt-4">
<ClientsTable
clients={pending}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => revoke.mutate(id)}
approvePending={approve.isPending}
rejectPending={revoke.isPending}
emptyTitle="Нет pending-запросов"
/>
</TabsContent>
</Tabs>
</div>
)
}
function ClientsTable({
clients,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}) {
if (clients.length === 0) {
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Last seen</TableHead>
<TableHead>Apply</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{clients.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.name}</div>
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
<TableCell className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
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"
className="text-destructive"
disabled={rejectPending}
>
Отозвать
</Button>
}
title="Отозвать клиент?"
description={`${c.name} — blocklist перестанет отдаваться, токен будет недействителен.`}
confirmLabel="Отозвать"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RulesTable({
rules,
communities,
onDelete,
}: {
rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[]
communities: BgpCommunity[]
onDelete: (id: string) => void
}) {
if (rules.length === 0) {
return <p className="text-muted-foreground text-sm">Нет правил blocklist пуст (default accept).</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Действие</TableHead>
<TableHead>Community</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>
<StatusBadge status={r.action} label={r.action} />
</TableCell>
<TableCell className="text-sm">
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
</TableCell>
<TableCell>{r.comment || '—'}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+87 -142
View File
@@ -1,44 +1,82 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Info, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { StatusBadge } from '@/components/status-badge'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions } from '@/queries/modules'
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
import { moduleTypeRu } from '@/lib/ui-labels'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
import type { AsEntry, ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent,
})
function moduleTypeAlert(type: ModuleRow['type']): string {
switch (type) {
case 'AS_PREFIXES':
return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
case 'CDN_CIDRS':
return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
case 'DOMAINS':
return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
case 'IP_RANGES':
return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
}
}
function ModuleDetailComponent() {
const { moduleId } = Route.useParams()
const queryClient = useQueryClient()
const detail = useQuery(moduleDetailQueryOptions(moduleId))
const mod = detail.data
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const entriesQuery = useQuery({
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
enabled: !!mod,
})
const asEntriesQ = useQuery({
...moduleEntriesQueryOptions(moduleId, 'AS_PREFIXES'),
enabled: mod?.type === 'AS_PREFIXES',
select: (data) => data.items as unknown as AsEntry[],
})
async function refreshAll() {
await Promise.all([detail.refetch(), entriesQuery.refetch(), communitiesQ.refetch(), dohQ.refetch()])
}
async function onEntriesChanged() {
await entriesQuery.refetch()
if (mod?.type === 'AS_PREFIXES') {
await queryClient.invalidateQueries({ queryKey: modulesKeys.asEntries(moduleId) })
}
await detail.refetch()
}
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
return (
<div className="flex flex-col gap-6">
<PageHeader
title={mod?.name ?? moduleId}
description={mod ? `Тип: ${mod.type}` : 'Загрузка модуля…'}
description={mod ? `Тип: ${moduleTypeRu(mod.type)} (${mod.type})` : 'Загрузка модуля…'}
actions={
<>
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
@@ -48,10 +86,7 @@ function ModuleDetailComponent() {
<Button
variant="outline"
size="sm"
onClick={() => {
void detail.refetch()
void entriesQuery.refetch()
}}
onClick={() => void refreshAll()}
disabled={detail.isFetching}
>
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
@@ -70,133 +105,43 @@ function ModuleDetailComponent() {
onRetry={() => detail.refetch()}
>
{(m) => (
<>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Параметры</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
<Field label="ID" value={<code className="font-mono text-xs">{m.id}</code>} />
<Field label="Тип" value={<Badge variant="outline">{m.type}</Badge>} />
<Field label="Приоритет" value={<span className="font-mono">{m.priority}</span>} />
<Field
label="Состояние"
value={
m.enabled ? (
<StatusBadge status="active" label="включён" />
) : (
<StatusBadge status="paused" label="выключен" />
)
}
/>
<Field
label="Интервал"
value={
m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—'
}
/>
<Field
label="Cron"
value={m.cron_expr ? <code className="font-mono text-xs">{m.cron_expr}</code> : '—'}
/>
<Field
label="Последний рефреш"
value={
m.last_refreshed_at
? new Date(m.last_refreshed_at).toLocaleString('ru-RU')
: '—'
}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Маршрутные списки</CardTitle>
<CardDescription>Источник префиксов для модуля</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={entriesQuery.data?.items}
isLoading={entriesQuery.isLoading}
isError={entriesQuery.isError}
error={entriesQuery.error}
empty={(entriesQuery.data?.items?.length ?? 0) === 0}
emptyTitle="Записей нет"
emptyDescription="Добавьте записи через API или создание ревизии."
skeleton={<TableSkeleton rows={5} cols={2} />}
onRetry={() => entriesQuery.refetch()}
>
{(items) => <EntriesTable items={items} moduleType={m.type} />}
</QueryState>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
{m.enabled ? (
<StatusBadge status="active" label="включён" />
) : (
<StatusBadge status="paused" label="выключен" />
)}
</div>
</>
<Alert>
<Info />
<AlertTitle>О модуле</AlertTitle>
<AlertDescription>{moduleTypeAlert(m.type)}</AlertDescription>
</Alert>
<ModuleKpiCards
mod={m}
communities={communities}
dohProfiles={dohProfiles}
asEntries={asEntries}
loading={detail.isLoading || communitiesQ.isLoading}
/>
<ModuleEntriesSection
moduleId={moduleId}
mod={m}
items={entriesQuery.data?.items ?? []}
communities={communities}
isLoading={entriesQuery.isLoading}
isError={entriesQuery.isError}
error={entriesQuery.error}
onRetry={() => entriesQuery.refetch()}
onChanged={onEntriesChanged}
/>
</div>
)}
</QueryState>
</div>
)
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span>{value}</span>
</div>
)
}
function EntriesTable({
items,
moduleType,
}: {
items: Record<string, unknown>[]
moduleType: string
}) {
const primary = ENTRY_PRIMARY_KEY[moduleType as keyof typeof ENTRY_PRIMARY_KEY] ?? 'id'
const secondary = ENTRY_SECONDARY_KEY[moduleType as keyof typeof ENTRY_SECONDARY_KEY] ?? null
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{primary}</TableHead>
{secondary ? <TableHead>{secondary}</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => {
const id = String(item.id ?? idx)
const primaryVal = String(item[primary] ?? '—')
return (
<TableRow key={id}>
<TableCell className="font-mono text-sm">{primaryVal}</TableCell>
{secondary ? (
<TableCell className="font-mono text-xs text-muted-foreground">
{String(item[secondary] ?? '—')}
</TableCell>
) : null}
</TableRow>
)
})}
</TableBody>
</Table>
)
}
const ENTRY_PRIMARY_KEY = {
DOMAINS: 'fqdn',
AS_PREFIXES: 'asn',
CDN_CIDRS: 'url',
IP_RANGES: 'prefix',
} as const
const ENTRY_SECONDARY_KEY = {
DOMAINS: 'community_id',
AS_PREFIXES: 'community_id',
CDN_CIDRS: 'community_id',
IP_RANGES: 'community_id',
} as const
+18 -5
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
@@ -12,6 +12,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
@@ -367,6 +368,14 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
const [a, setA] = useState('')
const [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
const revisionItems = useMemo(
() =>
revisions.map((r) => ({
value: r.id,
label: `${r.id.slice(0, 12)}`,
})),
[revisions],
)
return (
<Card>
@@ -377,8 +386,10 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Select value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>{a ? a.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
@@ -390,8 +401,10 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<Select value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>{b ? b.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
+82 -18
View File
@@ -1,17 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@evobgp/ui/components/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authSessionQueryOptions } from '@/queries/auth'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
import { Save } from 'lucide-react'
import { Info, Save } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
@@ -19,8 +27,21 @@ export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent,
})
const THEME_SELECT_ITEMS = [
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
{ value: 'system', label: 'Как в системе' },
] as const
function SettingsComponent() {
const { data: session } = useQuery(authSessionQueryOptions())
const navigate = useNavigate()
const qc = useQueryClient()
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
...authSessionQueryOptions(),
enabled: Boolean(
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
),
})
const { theme, setTheme } = useTheme()
const [token, setTokenValue] = useState('')
@@ -29,10 +50,23 @@ function SettingsComponent() {
setTokenValue(t)
}, [])
function saveTokenHandler() {
const t = token.trim()
setToken(t || null)
async function applyToken(raw: string) {
const normalized = normalizeApiToken(raw)
setToken(normalized || null)
setTokenValue(normalized)
await qc.invalidateQueries({ queryKey: authKeys.all })
toast.success('Токен сохранён')
if (normalized) {
void navigate({ to: '/dashboard' })
}
}
function saveTokenHandler() {
void applyToken(token)
}
function useDevToken() {
void applyToken(DEV_API_TOKEN)
}
return (
@@ -42,11 +76,21 @@ function SettingsComponent() {
description="Параметры интерфейса и подключения браузера к API."
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Локальная разработка</AlertTitle>
<AlertDescription>
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
<code className="text-xs">Bearer</code> он добавляется автоматически.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Подключение к API</CardTitle>
<CardDescription>
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant в
Токен хранится только в этом браузере (localStorage). Управление ключами tenant в
разделе «Права доступа».
</CardDescription>
</CardHeader>
@@ -59,19 +103,33 @@ function SettingsComponent() {
autoComplete="off"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="Bearer …"
placeholder="dev или API-ключ"
/>
</div>
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
<div className="flex flex-wrap gap-2">
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
<Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev
</Button>
</div>
{session ? (
<p className="text-xs text-muted-foreground">
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
<code className="font-mono">{session.role}</code>.
</p>
) : null}
{sessionError ? (
<p className="text-xs text-destructive">
{sessionQueryError instanceof Error
? sessionQueryError.message
: 'Не удалось проверить сессию'}
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
<code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API.
</p>
) : null}
</CardContent>
</Card>
@@ -84,8 +142,14 @@ function SettingsComponent() {
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label>
<Select value={theme ?? 'system'} onValueChange={(v) => v && setTheme(v)}>
<SelectTrigger id="theme-select" className="w-full max-w-xs" />
<Select
items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'}
onValueChange={(v) => v && setTheme(v)}
>
<SelectTrigger id="theme-select" className="w-full max-w-xs">
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
+19 -5
View File
@@ -13,6 +13,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
@@ -34,6 +35,7 @@ import {
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsKeys,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
@@ -48,6 +50,16 @@ export const Route = createFileRoute('/_auth/tenant-settings')({
}),
})
const RUNTIME_LOGS_ENABLED_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'truncate — обнулить' },
{ value: 'delete', label: 'delete — удалить файл' },
] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
@@ -82,7 +94,7 @@ function TenantSettingsComponent() {
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: ['settings'] })
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
@@ -241,6 +253,7 @@ function TenantSettingsComponent() {
<div className="flex flex-col gap-1.5">
<Label>Авто-очистка включена</Label>
<Select
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
onValueChange={(v) =>
v &&
@@ -251,7 +264,7 @@ function TenantSettingsComponent() {
}
>
<SelectTrigger>
{runtimeLogsForm.runtime_logs_auto_enabled === 'true' ? 'Вкл' : 'Выкл'}
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
@@ -298,6 +311,7 @@ function TenantSettingsComponent() {
<div className="flex flex-col gap-1.5">
<Label>Режим очистки</Label>
<Select
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
onValueChange={(v) =>
v &&
@@ -308,11 +322,11 @@ function TenantSettingsComponent() {
}
>
<SelectTrigger>
{runtimeLogsForm.runtime_logs_auto_mode || 'Выберите'}
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete</SelectItem>
<SelectItem value="truncate">Truncate</SelectItem>
<SelectItem value="truncate">truncate обнулить</SelectItem>
<SelectItem value="delete">delete удалить файл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
+37
View File
@@ -343,3 +343,40 @@ export type ApiKeyCreated = ApiKey & { token: string }
export type AsyncJobAccepted = {
job_id: string
}
// ---- Firewall blocklist ----
export type FirewallClient = {
id: string
name: string
hostname?: string
token_prefix: string
status: 'pending' | 'approved' | 'revoked'
last_seen_at?: string | null
last_seen_at_source?: string
last_apply_at?: string | null
last_apply_status?: string
last_apply_prefix_count?: number
last_apply_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
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/layout/app-shell.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
@@ -159,10 +159,18 @@ services:
condition: service_started
labels:
- traefik.enable=true
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
- traefik.http.routers.evobgp-firewall-public.tls=true
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-firewall-public.priority=100
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
- traefik.http.routers.evobgp-web.entrypoints=websecure
- traefik.http.routers.evobgp-web.tls=true
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-web.priority=10
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
+8
View File
@@ -247,10 +247,18 @@ services:
- evobgp-all
labels:
- traefik.enable=true
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
- traefik.http.routers.evobgp-firewall-public.tls=true
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-firewall-public.priority=100
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
- traefik.http.routers.evobgp-web.entrypoints=websecure
- traefik.http.routers.evobgp-web.tls=true
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-web.priority=10
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
+8
View File
@@ -162,10 +162,18 @@ services:
condition: service_started
labels:
- traefik.enable=true
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
- traefik.http.routers.evobgp-firewall-public.tls=true
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-firewall-public.priority=100
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
- traefik.http.routers.evobgp-web.entrypoints=websecure
- traefik.http.routers.evobgp-web.tls=true
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-web.priority=10
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
+1 -1
View File
@@ -18,7 +18,7 @@ apt-get install -y --no-install-recommends \
cd /tmp
rm -rf "bird-${BIRD_VERSION}"
curl -fsSL "https://bird.network.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
curl -fsSL "https://bird.nic.cz/download/bird-${BIRD_VERSION}.tar.gz" | tar xz
cd "bird-${BIRD_VERSION}"
./configure --prefix=/usr/local --enable-client
make -j"$(nproc)"
+1 -1
View File
@@ -5,7 +5,7 @@
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
WORKDIR /repo
RUN corepack enable
RUN corepack enable && corepack prepare [email protected] --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/ui/package.json ./packages/ui/
+1 -1
View File
@@ -17,7 +17,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location = /metrics {
+2
View File
@@ -58,6 +58,8 @@ RUN apt-get update \
FROM runtime-base AS runtime
ARG BIN=evobgp-api
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
COPY scripts/firewall /opt/evobgp/scripts/firewall
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
+3
View File
@@ -43,6 +43,7 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
| `editor` | 2 | Чтение + создание/изменение CRUD (модули, записи, peers и т.д.), без опасных операций уровня оператора. |
| `operator` | 3 | Полный операторский доступ: apply, rollback, настройки, отмена задач и т.п. (как задано в handlers). |
| `node` | отдельная | Только API для реплики: latest revision, скачивание бандла, enroll. Роль **`node` запрещена** для обычного CRUD — ответ `403 Forbidden`. |
| `firewall` | отдельная | Только data-plane firewall-клиента: `GET /v1/firewall/blocklist`, `POST /v1/firewall/apply-report`, `POST /v1/firewall/heartbeat`. Токен в таблице `firewall_client`, не в `api_key`. См. [firewall.md](firewall.md). |
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
@@ -50,6 +51,8 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
Без demo-tenant токен `dev` может быть задан в `EVOBGP_API_KEYS` (break-glass).
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
### PostgreSQL monitoring и maintenance (control plane)
+2 -1
View File
@@ -41,7 +41,8 @@
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik. |
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik; опционально firewall failover (`/v1/firewall/*`). |
| `firewall` | Вычисление policy block/accept → плоский CIDR blocklist. |
## Удалённые спикеры
+52
View File
@@ -0,0 +1,52 @@
# Firewall blocklist
Подсистема синхронизации blocklist на произвольные Linux-серверы через bash-скрипт и HTTP API.
## Авторизация
1. **Enroll**`POST /v1/firewall/enroll` с заголовком `X-EvoBGP-Seed` (значение `EVOBGP_BUNDLE_SEED_HEX` на CP). Клиент генерирует токен `evobgp_fw_*` локально.
2. **Approve** — operator в Web UI (`/firewall` → Запросы).
3. **Sync**`GET /v1/firewall/blocklist` с `Authorization: Bearer <client_token>`.
## Политика block/accept
- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist.
- **`accept`** — не блокировать префиксы этого community.
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
- **Default** — accept (пустой blocklist без явных `block`).
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
Справочник communities: Web UI → Справочники, или модули с привязкой community к префиксам.
## Установка на сервер
Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**.
Требуется миграция **`000027_firewall`** в PostgreSQL (применяется при старте API с актуальным бинарём). Если enroll отвечает `503` / `database schema outdated` — перезапустите `evobgp-api` / `evobgp-all` после деплоя новой версии.
```bash
curl -fsSL https://<api>/v1/firewall/install.sh | \
EVOBGP_CP_URL=https://<api> \
EVOBGP_SEED=<bundle_seed_hex> \
EVOBGP_CLIENT_NAME="web-01" \
bash
```
Файлы: `/etc/evobgp/firewall.conf`, `/usr/local/sbin/evobgp-firewall.sh`, systemd timer `evobgp-firewall.timer`.
После **approve** в UI выполните на сервере (или дождитесь timer):
```bash
sudo rm -f /var/lib/evobgp-firewall/last_hash
sudo /usr/local/sbin/evobgp-firewall.sh
sudo nft list table inet evobgp_blocklist
```
Для парсинга JSON нужен `jq` или `python3` (install.sh ставит `jq` на Debian/Ubuntu при отсутствии).
## Failover через speaker
При `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` на speaker-agent CP реплицирует состояние через `POST /v1/agent/firewall-replicate`. Клиенты используют тот же DNS-домен.
См. также [access.md](access.md), [remote-speakers.md](remote-speakers.md).
+253
View File
@@ -58,6 +58,8 @@ tags:
Файловые runtime-логи Docker-сервисов (каталог EVOBGP_RUNTIME_LOGS_DIR).
Доступно только в процессе evobgp-all с примонтированным volume; иначе 503.
Просмотр — viewer+; очистка — operator+ (синхронно, с audit).
- name: Firewall
description: Linux firewall blocklist clients, policy rules (block/accept), and data-plane sync.
security:
- bearerAuth: []
@@ -1579,6 +1581,84 @@ components:
type: string
format: date-time
FirewallClient:
type: object
properties:
id:
$ref: "#/components/schemas/ResourceId"
name:
type: string
hostname:
type: string
token_prefix:
type: string
status:
type: string
enum: [pending, approved, revoked]
last_seen_at:
type: string
format: date-time
last_apply_at:
type: string
format: date-time
last_apply_status:
type: string
last_apply_prefix_count:
type: integer
client_version:
type: string
FirewallInstallContext:
type: object
description: Контекст для one-liner установки firewall-клиента (только operator).
properties:
bundle_seed:
type: string
description: Значение EVOBGP_BUNDLE_SEED_HEX на control plane.
bundle_seed_configured:
type: boolean
suggested_cp_url:
type: string
format: uri
install_sh_url:
type: string
format: uri
FirewallRule:
type: object
properties:
id:
$ref: "#/components/schemas/ResourceId"
client_id:
$ref: "#/components/schemas/ResourceId"
nullable: true
priority:
type: integer
action:
type: string
enum: [block, accept]
community_id:
$ref: "#/components/schemas/ResourceId"
nullable: true
comment:
type: string
FirewallBlocklist:
type: object
properties:
client_id:
$ref: "#/components/schemas/ResourceId"
revision_id:
$ref: "#/components/schemas/ResourceId"
prefixes:
type: array
items:
type: string
total:
type: integer
hash:
type: string
paths:
/v1/health:
get:
@@ -4299,3 +4379,176 @@ paths:
$ref: "#/components/responses/Forbidden"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/install-context:
get:
tags: [Firewall]
summary: Install context for firewall one-liner (operator)
operationId: getFirewallInstallContext
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/FirewallInstallContext"
"403":
$ref: "#/components/responses/Forbidden"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/enroll:
post:
tags: [Firewall]
summary: Enroll firewall client (public, X-EvoBGP-Seed)
security: []
operationId: firewallEnroll
parameters:
- name: X-EvoBGP-Seed
in: header
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, client_token]
properties:
name:
type: string
hostname:
type: string
client_token:
type: string
client_version:
type: string
responses:
"201":
description: Client created (pending).
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/clients:
get:
tags: [Firewall]
summary: List firewall clients
operationId: listFirewallClients
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/FirewallClient"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/clients/{id}/approve:
post:
tags: [Firewall]
summary: Approve pending client
operationId: approveFirewallClient
parameters:
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Approved
content:
application/json:
schema:
$ref: "#/components/schemas/FirewallClient"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/clients/{id}/revoke:
post:
tags: [Firewall]
summary: Reject pending or revoke approved client
operationId: revokeFirewallClient
parameters:
- name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: Revoked
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [revoked]
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/rules:
get:
tags: [Firewall]
summary: List firewall rules
operationId: listFirewallRules
parameters:
- name: scope
in: query
schema:
type: string
enum: [tenant, client]
- name: client_id
in: query
schema:
$ref: "#/components/schemas/ResourceId"
responses:
"200":
description: OK
default:
$ref: "#/components/responses/DefaultProblem"
post:
tags: [Firewall]
summary: Create firewall rule
operationId: createFirewallRule
responses:
"201":
description: Created
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/blocklist:
get:
tags: [Firewall]
summary: Get evaluated blocklist (firewall client token)
operationId: getFirewallBlocklist
responses:
"200":
description: Blocklist
content:
application/json:
schema:
$ref: "#/components/schemas/FirewallBlocklist"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/firewall/apply-report:
post:
tags: [Firewall]
summary: Report last apply status
operationId: firewallApplyReport
responses:
"200":
description: OK
default:
$ref: "#/components/responses/DefaultProblem"
+1
View File
@@ -153,6 +153,7 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u
- `http://<WEBUI_DOMAIN>` должен редиректить на `https://<WEBUI_DOMAIN>`;
- с IP из `WEBUI_IP_WHITELIST` UI доступен по HTTPS;
- с неразрешенного IP Traefik вернет `403`.
- исключение: `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll` — публичные, без whitelist (см. [firewall.md](firewall.md)).
Health API: `http://<IP>:8080/v1/health`.
+2
View File
@@ -114,5 +114,7 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
| `EVOBGP_AGENT_SECRET` | реплика |
| `EVOBGP_NODE_TOKEN` | реплика |
| `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` | реплика (опционально: отдавать `/v1/firewall/blocklist` при недоступности CP) |
| `EVOBGP_FIREWALL_STATE_FILE` | реплика (default `/var/lib/evobgp-agent/firewall-state.json`) |
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика |
| `PANEL_IP_WHITELIST` | Traefik на реплике |
+256
View File
@@ -0,0 +1,256 @@
package agentserver
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"evobgp/internal/authkey"
"evobgp/internal/firewall"
)
type firewallReplicatePayload struct {
TenantID string `json:"tenant_id"`
RevisionID string `json:"revision_id"`
GeneratedAt string `json:"generated_at"`
UpdatedAt string `json:"updated_at"`
ClientsByHash map[string]clientMeta `json:"clients_by_hash"`
Rules []firewallRuleJSON `json:"rules"`
PrefixesByCommunity map[string][]string `json:"prefixes_by_community"`
}
type clientMeta struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
}
type firewallRuleJSON struct {
ClientID *string `json:"client_id"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id"`
}
type firewallAgent struct {
mu sync.RWMutex
stateFile string
cpURL string
enabled bool
state firewallReplicatePayload
}
func newFirewallAgent(cfg Config) *firewallAgent {
enabled := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_FAILOVER_ENABLED")) == "1"
stateFile := envOr("EVOBGP_FIREWALL_STATE_FILE", "/var/lib/evobgp-agent/firewall-state.json")
fa := &firewallAgent{
stateFile: stateFile,
cpURL: strings.TrimSpace(cfg.ControlPlaneURL),
enabled: enabled,
}
if enabled {
_ = fa.load()
}
return fa
}
func (s *Server) handleFirewallReplicate(w http.ResponseWriter, r *http.Request) {
if !s.authorize(r) {
writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization")
return
}
var raw struct {
TenantID string `json:"tenant_id"`
RevisionID string `json:"revision_id"`
GeneratedAt string `json:"generated_at"`
Clients []struct {
TokenHashHex string `json:"token_hash_hex"`
ClientID string `json:"client_id"`
Name string `json:"name"`
} `json:"clients"`
Rules []firewallRuleJSON `json:"rules"`
PrefixesByCommunity map[string][]string `json:"prefixes_by_community"`
}
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid JSON body")
return
}
byHash := make(map[string]clientMeta, len(raw.Clients))
for _, c := range raw.Clients {
byHash[strings.ToLower(c.TokenHashHex)] = clientMeta{ClientID: c.ClientID, Name: c.Name}
}
payload := firewallReplicatePayload{
TenantID: raw.TenantID,
RevisionID: raw.RevisionID,
GeneratedAt: raw.GeneratedAt,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
ClientsByHash: byHash,
Rules: raw.Rules,
PrefixesByCommunity: raw.PrefixesByCommunity,
}
s.firewall.mu.Lock()
s.firewall.state = payload
s.firewall.mu.Unlock()
if err := s.firewall.save(); err != nil {
log.Printf("agentserver: firewall state save: %v", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"clients_count": len(byHash),
"rules_count": len(raw.Rules),
"prefix_count": len(raw.PrefixesByCommunity),
})
}
func (fa *firewallAgent) handleBlocklist(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeProblem(w, http.StatusUnauthorized, "missing bearer token")
return
}
hash := hex.EncodeToString(authkey.HashToken(token))
fa.mu.RLock()
st := fa.state
meta, ok := st.ClientsByHash[hash]
fa.mu.RUnlock()
if !ok {
writeProblem(w, http.StatusUnauthorized, "unknown firewall client")
return
}
rules := make([]firewall.Rule, 0, len(st.Rules))
for _, r := range st.Rules {
rules = append(rules, firewall.Rule{
ClientID: r.ClientID,
Priority: r.Priority,
Action: r.Action,
CommunityID: r.CommunityID,
})
}
prefixes := firewall.Evaluate(meta.ClientID, rules, st.PrefixesByCommunity)
updatedAt, _ := time.Parse(time.RFC3339, st.UpdatedAt)
age := int(time.Since(updatedAt).Seconds())
if updatedAt.IsZero() {
age = 0
}
w.Header().Set("X-EvoBGP-Source", "speaker")
w.Header().Set("X-EvoBGP-Revision-ID", st.RevisionID)
w.Header().Set("X-EvoBGP-Generated-At", st.GeneratedAt)
w.Header().Set("X-EvoBGP-Rules-Version", firewall.RulesVersionHash(rules))
if age > 3600 {
w.Header().Set("X-EvoBGP-Stale", "true")
}
if age > 0 {
w.Header().Set("Age", strconvItoa(age))
}
resp := map[string]any{
"client_id": meta.ClientID,
"revision_id": st.RevisionID,
"generated_at": st.GeneratedAt,
"source": "speaker",
"rules_applied": len(rules),
"communities_evaluated": len(st.PrefixesByCommunity),
"prefixes": prefixes,
"total": len(prefixes),
"hash": prefixHash(prefixes),
}
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for _, p := range prefixes {
_, _ = w.Write([]byte(p + "\n"))
}
return
}
writeJSON(w, http.StatusOK, resp)
}
func (fa *firewallAgent) handleApplyReportForward(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
forwarded := false
if fa.cpURL != "" {
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, strings.TrimSuffix(fa.cpURL, "/")+"/v1/firewall/apply-report", strings.NewReader(string(body)))
if err == nil {
req.Header.Set("Authorization", r.Header.Get("Authorization"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err == nil {
forwarded = resp.StatusCode >= 200 && resp.StatusCode < 300
_ = resp.Body.Close()
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "forwarded": forwarded})
}
func (fa *firewallAgent) load() error {
b, err := os.ReadFile(fa.stateFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var st firewallReplicatePayload
if err := json.Unmarshal(b, &st); err != nil {
return err
}
fa.mu.Lock()
fa.state = st
fa.mu.Unlock()
return nil
}
func (fa *firewallAgent) save() error {
fa.mu.RLock()
b, err := json.MarshalIndent(fa.state, "", " ")
fa.mu.RUnlock()
if err != nil {
return err
}
dir := filepath.Dir(fa.stateFile)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := fa.stateFile + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, fa.stateFile)
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
return ""
}
return strings.TrimSpace(h[len(p):])
}
func prefixHash(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 strconvItoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+9 -3
View File
@@ -36,16 +36,22 @@ type Config struct {
// Server serves Panel→Node internal API (Remnawave-style wake-up).
type Server struct {
cfg Config
mux *http.ServeMux
cfg Config
mux *http.ServeMux
firewall *firewallAgent
}
// New builds an agent HTTP server.
func New(cfg Config) *Server {
s := &Server{cfg: cfg, mux: http.NewServeMux()}
s := &Server{cfg: cfg, mux: http.NewServeMux(), firewall: newFirewallAgent(cfg)}
s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols)
s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync)
if s.firewall.enabled {
s.mux.HandleFunc("GET /v1/firewall/blocklist", s.firewall.handleBlocklist)
s.mux.HandleFunc("POST /v1/firewall/apply-report", s.firewall.handleApplyReportForward)
s.mux.HandleFunc("POST /v1/agent/firewall-replicate", s.handleFirewallReplicate)
}
return s
}
+127
View File
@@ -0,0 +1,127 @@
// Package firewall evaluates block/accept policy rules into CIDR blocklists.
package firewall
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
)
// Rule is one ordered firewall policy rule for evaluation.
type Rule struct {
ClientID *string
Priority int
Action string // "block" | "accept"
CommunityID *string
}
// Evaluate returns a deduplicated flat CIDR list to block in the kernel.
// communityPrefixes maps community ID to prefixes; key "" holds prefixes without community.
// Default when no rule matches: accept (do not block).
func Evaluate(clientID string, rules []Rule, communityPrefixes map[string][]string) []string {
ordered := mergeRules(clientID, rules)
if len(communityPrefixes) == 0 {
return nil
}
keys := make([]string, 0, len(communityPrefixes))
for k := range communityPrefixes {
keys = append(keys, k)
}
sort.Strings(keys)
var out []string
seen := make(map[string]struct{})
for _, commKey := range keys {
if !shouldBlockCommunity(commKey, ordered) {
continue
}
for _, p := range communityPrefixes[commKey] {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
}
return out
}
func mergeRules(clientID string, rules []Rule) []Rule {
var clientRules, tenantRules []Rule
for _, r := range rules {
if r.ClientID != nil && strings.TrimSpace(*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 := make([]Rule, 0, len(clientRules)+len(tenantRules))
out = append(out, clientRules...)
out = append(out, tenantRules...)
return out
}
func shouldBlockCommunity(communityKey string, ordered []Rule) bool {
for _, r := range ordered {
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == communityKey {
return strings.EqualFold(strings.TrimSpace(r.Action), "block")
}
}
return false
}
// RulesVersionHash returns a stable fingerprint of rules for cache headers.
func RulesVersionHash(rules []Rule) string {
if len(rules) == 0 {
return "sha256:empty"
}
cp := append([]Rule(nil), rules...)
sort.Slice(cp, func(i, j int) bool {
a, b := cp[i], cp[j]
ac, bc := "", ""
if a.ClientID != nil {
ac = *a.ClientID
}
if b.ClientID != nil {
bc = *b.ClientID
}
if ac != bc {
return ac < bc
}
if a.Priority != b.Priority {
return a.Priority < b.Priority
}
return a.Action < b.Action
})
var b strings.Builder
for _, r := range cp {
cid := "*"
if r.CommunityID != nil {
cid = *r.CommunityID
}
cl := "tenant"
if r.ClientID != nil {
cl = *r.ClientID
}
b.WriteString(cl)
b.WriteByte('|')
b.WriteString(r.Action)
b.WriteByte('|')
b.WriteString(cid)
b.WriteByte('|')
b.WriteString(strconv.Itoa(r.Priority))
b.WriteByte(';')
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:])
}
+75
View File
@@ -0,0 +1,75 @@
package firewall
import (
"testing"
)
func TestEvaluate_emptyRules(t *testing.T) {
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
got := Evaluate("client1", nil, prefixes)
if len(got) != 0 {
t.Fatalf("expected empty blocklist, got %v", got)
}
}
func TestEvaluate_onlyAccept(t *testing.T) {
wild := (*string)(nil)
rules := []Rule{{Priority: 1, Action: "accept", CommunityID: wild}}
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
got := Evaluate("client1", rules, prefixes)
if len(got) != 0 {
t.Fatalf("accept alone must not block, got %v", got)
}
}
func TestEvaluate_explicitBlock(t *testing.T) {
cid := "c1"
rules := []Rule{{Priority: 1, Action: "block", CommunityID: &cid}}
prefixes := map[string][]string{"c1": {"1.2.3.0/24", "5.6.7.8/32"}, "c2": {"9.9.9.9/32"}}
got := Evaluate("client1", rules, prefixes)
if len(got) != 2 {
t.Fatalf("expected 2 prefixes, got %v", got)
}
}
func TestEvaluate_clientOverrideAccept(t *testing.T) {
wild := (*string)(nil)
cTrusted := "trusted"
clientID := "srv1"
rules := []Rule{
{Priority: 1, Action: "block", CommunityID: wild},
{ClientID: &clientID, Priority: 1, Action: "accept", CommunityID: &cTrusted},
}
prefixes := map[string][]string{
"trusted": {"1.1.1.0/24"},
"bad": {"2.2.2.0/24"},
}
got := Evaluate(clientID, rules, prefixes)
if len(got) != 1 || got[0] != "2.2.2.0/24" {
t.Fatalf("expected only bad community, got %v", got)
}
}
func TestEvaluate_clientOverrideWins(t *testing.T) {
cid := "c1"
clientID := "srv1"
rules := []Rule{
{Priority: 1, Action: "accept", CommunityID: &cid},
{ClientID: &clientID, Priority: 1, Action: "block", CommunityID: &cid},
}
prefixes := map[string][]string{"c1": {"1.2.3.0/24"}}
got := Evaluate(clientID, rules, prefixes)
if len(got) != 1 {
t.Fatalf("client override should block, got %v", got)
}
}
func TestEvaluate_noCommunityKey(t *testing.T) {
empty := ""
rules := []Rule{{Priority: 1, Action: "block", CommunityID: &empty}}
prefixes := map[string][]string{"": {"10.0.0.0/8"}}
got := Evaluate("c", rules, prefixes)
if len(got) != 1 {
t.Fatalf("expected prefix without community, got %v", got)
}
}
+10
View File
@@ -0,0 +1,10 @@
// Package firewallscripts embeds bash installers served by GET /v1/firewall/install.sh.
// Источник правды — scripts/firewall/; при изменении скопируйте файлы сюда или запустите:
//
// go generate ./internal/firewallscripts/...
package firewallscripts
import "embed"
//go:embed install.sh evobgp-firewall.sh uninstall.sh
var FS embed.FS
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
set -euo pipefail
CONF_FILE=/etc/evobgp/firewall.conf
LOG_FILE=/var/log/evobgp-firewall.log
STATE_DIR=/var/lib/evobgp-firewall
HASH_FILE="${STATE_DIR}/last_hash"
PREFIX_FILE="${STATE_DIR}/last_prefixes.txt"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
if [[ ! -f "$CONF_FILE" ]]; then
log "missing $CONF_FILE"
exit 1
fi
# shellcheck disable=SC1090
source "$CONF_FILE"
: "${EVOBGP_CP_URL:?}"
: "${CLIENT_TOKEN:?}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
mkdir -p "$STATE_DIR"
BACKEND="${KERNEL_BACKEND:-auto}"
curl_get_blocklist_file() {
local url="$1"
local dest="$2"
local code
code=$(curl -sS -o "$dest" -w "%{http_code}" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Accept: application/json" \
"${url}/v1/firewall/blocklist") || return 1
if [[ "$code" == "403" ]]; then
log "pending approval"
return 2
fi
if [[ "$code" != "200" ]]; then
log "blocklist HTTP $code from $url"
return 1
fi
return 0
}
try_fetch_blocklist() {
local urls=()
if [[ -n "${EVOBGP_FAILOVER_URLS:-}" ]]; then
IFS=',' read -r -a urls <<<"$EVOBGP_FAILOVER_URLS"
else
urls=("${EVOBGP_CP_URL%/}")
fi
local u
for u in "${urls[@]}"; do
u="${u// /}"
u="${u%/}"
local rc=0
curl_get_blocklist_file "$u" "$PREFIX_FILE" || rc=$?
if [[ "$rc" == 2 ]]; then
exit 0
fi
if [[ "$rc" == 0 ]]; then
CP_HIT="$u"
return 0
fi
done
return 1
}
parse_blocklist_file() {
local f="$1"
if [[ ! -s "$f" ]]; then
log "blocklist file empty: $f"
return 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
TOTAL=$(jq -r '.total // 0' "$f")
mapfile -t PREFIXES < <(jq -r '.prefixes[]? // empty' "$f")
return 0
fi
if command -v python3 >/dev/null 2>&1; then
local parsed
parsed=$(python3 - "$f" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as fh:
data = json.load(fh)
print(data.get("hash") or "")
print(data.get("total") or 0)
for p in data.get("prefixes") or []:
if p:
print(p)
PY
)
HASH=$(echo "$parsed" | sed -n '1p')
TOTAL=$(echo "$parsed" | sed -n '2p')
mapfile -t PREFIXES < <(echo "$parsed" | sed -n '3,$p')
return 0
fi
HASH=$(grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/')
TOTAL=$(grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' "$f" | head -1 | grep -o '[0-9]*$' || true)
mapfile -t PREFIXES < <(grep -oE '"[0-9]+(\.[0-9]+){3}/[0-9]+"' "$f" | tr -d '"' || true)
return 0
}
nft_join_elements() {
local out="" p
for p in "$@"; do
if [[ -n "$out" ]]; then
out+=", "
fi
out+="$p"
done
printf '%s' "$out"
}
nft_add_v4_chunk() {
local table=$1 name=$2
shift 2
local joined
joined=$(nft_join_elements "$@")
if nft add element "$table" "$name" v4 "{ ${joined} }" 2>>"$LOG_FILE"; then
return 0
fi
log "nft batch add failed (chunk=$#), retrying one-by-one"
local p ok=0
for p in "$@"; do
if nft add element "$table" "$name" v4 "{ $p }" 2>>"$LOG_FILE"; then
ok=$((ok + 1))
fi
done
[[ "$ok" -gt 0 ]]
}
if ! try_fetch_blocklist; then
log "all endpoints failed"
exit 1
fi
HASH=""
TOTAL=0
PREFIXES=()
parse_blocklist_file "$PREFIX_FILE"
log "blocklist bytes=$(wc -c <"$PREFIX_FILE" | tr -d ' ') parsed=${#PREFIXES[@]} api_total=${TOTAL:-0}"
if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply"
exit 0
fi
apply_nft() {
local table=inet
local name=evobgp_blocklist
local v4=()
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
v4+=("$p")
done
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
nft list set "$table" "$name" v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" v4 '{ type ipv4_addr; flags interval; }'
nft flush set "$table" "$name" v4
if ((${#v4[@]})); then
local batch=()
local chunk=64
for p in "${v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft chunk add partial failure"
batch=()
fi
done
if ((${#batch[@]})); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft tail chunk add partial failure"
fi
fi
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 drop
}
APPLIED_V4=${#v4[@]}
}
apply_ipset() {
local set=evobgp_blocklist_v4
local n=0
ipset list "$set" >/dev/null 2>&1 || ipset create "$set" hash:net family inet hashsize 4096 maxelem 1048576
ipset flush "$set"
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
ipset add "$set" "$p" -exist
n=$((n + 1))
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
APPLIED_V4=$n
}
apply_iptables_only() {
iptables -D INPUT -m comment --comment evobgp-block -j DROP 2>/dev/null || true
local n=0
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
APPLIED_V4=$n
}
clear_block() {
case "$BACKEND" in
nft) nft delete table inet evobgp_blocklist 2>/dev/null || true ;;
ipset)
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
;;
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
}
APPLIED_V4=0
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
else
case "$BACKEND" in
nft|auto) if command -v nft >/dev/null 2>&1; then apply_nft; else apply_ipset; fi ;;
ipset) apply_ipset ;;
iptables) apply_iptables_only ;;
*) apply_ipset ;;
esac
log "applied api_total=${TOTAL} ipv4_in_kernel=${APPLIED_V4} from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND hash=${HASH:-empty}"
fi
echo "$HASH" >"$HASH_FILE"
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "evobgp-firewall install: run as root" >&2
exit 1
fi
for cmd in curl bash; do
command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; }
done
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq jq
fi
fi
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
echo "evobgp-firewall install: install jq or python3 for blocklist JSON parsing" >&2
exit 1
fi
: "${EVOBGP_CP_URL:?EVOBGP_CP_URL required}"
: "${EVOBGP_SEED:?EVOBGP_SEED required}"
: "${EVOBGP_CLIENT_NAME:?EVOBGP_CLIENT_NAME required}"
CONF_DIR=/etc/evobgp
CONF_FILE="${CONF_DIR}/firewall.conf"
SYNC_SCRIPT=/usr/local/sbin/evobgp-firewall.sh
if [[ -f "$CONF_FILE" && "${EVOBGP_INSTALL_FORCE:-}" != "1" ]]; then
echo "Already installed ($CONF_FILE). Set EVOBGP_INSTALL_FORCE=1 to reinstall." >&2
exit 1
fi
gen_token() {
if command -v openssl >/dev/null 2>&1; then
echo -n "evobgp_fw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
else
echo -n "evobgp_fw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
fi
}
CLIENT_TOKEN="$(gen_token)"
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
CP_URL="${EVOBGP_CP_URL%/}"
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","client_token":"%s","client_version":"install.sh/1"}' \
"$EVOBGP_CLIENT_NAME" "$HOSTNAME" "$CLIENT_TOKEN")
ENROLL_TMP=$(mktemp)
trap 'rm -f "$ENROLL_TMP"' EXIT
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/firewall/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" ]]; then
echo "evobgp-firewall enroll failed: HTTP ${ENROLL_CODE} from ${CP_URL}/v1/firewall/enroll" >&2
cat "$ENROLL_TMP" >&2
exit 1
fi
RESP=$(cat "$ENROLL_TMP")
CLIENT_ID=""
if command -v jq >/dev/null 2>&1; then
CLIENT_ID=$(echo "$RESP" | jq -r '.client_id')
else
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
fi
mkdir -p "$CONF_DIR"
chmod 700 "$CONF_DIR"
cat >"$CONF_FILE" <<EOF
EVOBGP_CP_URL=${CP_URL}
CLIENT_ID=${CLIENT_ID}
CLIENT_TOKEN=${CLIENT_TOKEN}
CLIENT_NAME=${EVOBGP_CLIENT_NAME}
KERNEL_BACKEND=auto
EOF
chmod 600 "$CONF_FILE"
curl -fsSL "${CP_URL}/v1/firewall/sync-script" -o "$SYNC_SCRIPT"
chmod 755 "$SYNC_SCRIPT"
if command -v nft >/dev/null 2>&1; then
BACKEND=nft
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
BACKEND=ipset
elif command -v iptables >/dev/null 2>&1; then
BACKEND=iptables
else
echo "no supported firewall backend (nft/ipset/iptables)" >&2
exit 1
fi
sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \
echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE"
INTERVAL="${EVOBGP_SYNC_INTERVAL:-5min}"
if command -v systemctl >/dev/null 2>&1; then
cat >/etc/systemd/system/evobgp-firewall.service <<'UNIT'
[Unit]
Description=EvoBGP firewall blocklist sync
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/evobgp-firewall.sh
UNIT
cat >/etc/systemd/system/evobgp-firewall.timer <<UNIT
[Unit]
Description=EvoBGP firewall sync timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=${INTERVAL}
Unit=evobgp-firewall.service
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now evobgp-firewall.timer
echo "Tip: after UI approve, run: rm -f /var/lib/evobgp-firewall/last_hash && ${SYNC_SCRIPT}"
else
echo "*/5 * * * * root ${SYNC_SCRIPT}" >/etc/cron.d/evobgp-firewall
fi
echo "Client ID: ${CLIENT_ID}"
echo "Status: pending — approve in EvoBGP UI → Firewall → Запросы"
+34
View File
@@ -0,0 +1,34 @@
package firewallscripts
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestScriptsMatchRepoSource(t *testing.T) {
root := filepath.Join("..", "..", "scripts", "firewall")
for _, name := range []string{"install.sh", "evobgp-firewall.sh", "uninstall.sh"} {
embedded, err := FS.ReadFile(name)
if err != nil {
t.Fatalf("embedded %s: %v", name, err)
}
source, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Skipf("source %s not found (cwd=%s): %v", name, mustWd(t), err)
}
if !bytes.Equal(embedded, source) {
t.Fatalf("%s drift: copy scripts/firewall/%s to internal/firewallscripts/", name, name)
}
}
}
func mustWd(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return wd
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
systemctl disable --now evobgp-firewall.timer 2>/dev/null || true
rm -f /etc/cron.d/evobgp-firewall
rm -f /etc/systemd/system/evobgp-firewall.service /etc/systemd/system/evobgp-firewall.timer
systemctl daemon-reload 2>/dev/null || true
nft delete table inet evobgp_blocklist 2>/dev/null || true
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
rm -f /usr/local/sbin/evobgp-firewall.sh /usr/local/sbin/evobgp-firewall-uninstall.sh
rm -rf /var/lib/evobgp-firewall
if [[ "${EVOBGP_UNINSTALL_REMOVE_CONF:-}" == "1" ]]; then
rm -f /etc/evobgp/firewall.conf
fi
echo "evobgp-firewall uninstalled"
+45 -11
View File
@@ -4,6 +4,8 @@ import (
"context"
"net/http"
"strings"
"evobgp/internal/authkey"
)
type ctxKey int
@@ -63,27 +65,51 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
if raw == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
matched, ok := s.keyResolver.Lookup(raw)
a, ok := s.resolveAuth(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
if matched.keyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
if a.APIKeyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID)
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
}
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
}
// resolveAuth maps a bearer token to tenant identity.
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
// is available; env/DB mapping is used only when demo tenant is absent.
func (s *Server) resolveAuth(raw string) (Auth, bool) {
if raw == "dev" {
if a, ok := s.devAuth(); ok {
return a, true
}
if rec, ok := s.keyResolver.Lookup(raw); ok {
return authFromKeyRecord(raw, rec), true
}
return Auth{}, false
}
rec, ok := s.keyResolver.Lookup(raw)
if !ok {
if s.firewallResolver != nil {
if fw, ok := s.firewallResolver.Lookup(raw); ok {
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
}
}
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
}
return Auth{}, false
}
return authFromKeyRecord(raw, rec), true
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
@@ -118,6 +144,14 @@ func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool
return true
}
func (s *Server) requireFirewall(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "firewall" {
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall client role required")
return false
}
return true
}
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
+101
View File
@@ -0,0 +1,101 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestBearerDevGetSettings(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()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/settings", nil)
req.Header.Set("Authorization", "Bearer dev")
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 TestBearerDevPrefersDemoTenantOverEnvKey(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
demoTenant, _, _, _, _ := srv.Store().DemoIDs()
otherTenant := "00000000-0000-4000-8000-000000000001"
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
req.Header.Set("Authorization", "Bearer dev")
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("session status=%d body=%s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
got, _ := body["tenant_id"].(string)
if got != demoTenant {
t.Fatalf("tenant_id=%q want demo tenant %q (env=%q)", got, demoTenant, otherTenant)
}
}
func TestBearerDevFallsBackToEnvKeyWithoutDemo(t *testing.T) {
srv, err := New(Options{SeedDemo: false, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
otherTenant := "00000000-0000-4000-8000-000000000001"
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
req.Header.Set("Authorization", "Bearer dev")
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("session status=%d body=%s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
got, _ := body["tenant_id"].(string)
if got != otherTenant {
t.Fatalf("tenant_id=%q want env key tenant %q", got, otherTenant)
}
}
+118
View File
@@ -0,0 +1,118 @@
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
}
+57
View File
@@ -0,0 +1,57 @@
package httpapi
import (
"encoding/hex"
"sync"
"evobgp/internal/authkey"
"evobgp/internal/store"
)
type firewallAuthRow struct {
tenantID string
clientID string
}
type firewallTokenResolver struct {
mu sync.RWMutex
byHash map[string]firewallAuthRow
}
func newFirewallTokenResolver(st store.Backend) (*firewallTokenResolver, error) {
r := &firewallTokenResolver{byHash: make(map[string]firewallAuthRow)}
return r, r.reloadFromStore(st)
}
func (r *firewallTokenResolver) reloadFromStore(st store.Backend) error {
rows, err := st.ListActiveFirewallClientHashes()
if err != nil {
return err
}
byHash := make(map[string]firewallAuthRow, len(rows))
for _, row := range rows {
if len(row.TokenHash) != 32 {
continue
}
byHash[hex.EncodeToString(row.TokenHash)] = firewallAuthRow{
tenantID: row.TenantID,
clientID: row.ID,
}
}
r.mu.Lock()
r.byHash = byHash
r.mu.Unlock()
return nil
}
func (r *firewallTokenResolver) Reload(st store.Backend) error {
return r.reloadFromStore(st)
}
func (r *firewallTokenResolver) Lookup(raw string) (firewallAuthRow, bool) {
hash := authkey.HashToken(raw)
r.mu.RLock()
defer r.mu.RUnlock()
rec, ok := r.byHash[hex.EncodeToString(hash)]
return rec, ok
}
+4
View File
@@ -35,6 +35,9 @@ func (s *Server) Handler() http.Handler {
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
return s.withCORS(observability.HTTPMiddleware(s.mux))
}
@@ -82,6 +85,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
s.registerPostgresMaintenanceRoutes(m)
s.registerMaintenanceRoutes(m)
s.registerRuntimeLogsRoutes(m)
s.registerFirewallRoutes(m)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+22
View File
@@ -16,6 +16,8 @@ import (
"evobgp/internal/pipeline"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
"github.com/jackc/pgx/v5/pgconn"
)
func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
@@ -188,9 +190,29 @@ func writeStoreErr(w http.ResponseWriter, err error) {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
if writePostgresStoreErr(w, err) {
return
}
writeInternalError(w, "store", err)
}
func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
switch pgErr.Code {
case "42P01":
writeProblem(w, http.StatusServiceUnavailable, "Service Unavailable",
"database schema outdated; restart API after deploy or apply migration 000027_firewall")
return true
case "23505":
writeProblem(w, http.StatusConflict, "Conflict", "resource already exists")
return true
}
return false
}
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
+658
View File
@@ -0,0 +1,658 @@
package httpapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"evobgp/internal/authkey"
"evobgp/internal/firewall"
"evobgp/internal/firewallscripts"
"evobgp/internal/store"
)
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext)
m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients)
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview)
m.HandleFunc("PATCH /firewall/clients/{id}", s.handlePatchFirewallClient)
m.HandleFunc("POST /firewall/clients/{id}/approve", s.handleApproveFirewallClient)
m.HandleFunc("POST /firewall/clients/{id}/revoke", s.handleRevokeFirewallClient)
m.HandleFunc("DELETE /firewall/clients/{id}", s.handleDeleteFirewallClient)
m.HandleFunc("GET /firewall/rules", s.handleListFirewallRules)
m.HandleFunc("POST /firewall/rules", s.handleCreateFirewallRule)
m.HandleFunc("PATCH /firewall/rules/{id}", s.handlePatchFirewallRule)
m.HandleFunc("DELETE /firewall/rules/{id}", s.handleDeleteFirewallRule)
m.HandleFunc("POST /firewall/rules:reorder", s.handleReorderFirewallRules)
m.HandleFunc("GET /firewall/blocklist", s.handleFirewallBlocklist)
m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport)
m.HandleFunc("POST /firewall/heartbeat", s.handleFirewallHeartbeat)
}
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
seed := strings.TrimSpace(s.bundleSeedHex)
writeJSON(w, http.StatusOK, map[string]any{
"bundle_seed": seed,
"bundle_seed_configured": seed != "",
"suggested_cp_url": publicHTTPSBaseURL(r),
"install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh",
})
}
// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links.
func publicHTTPSBaseURL(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" {
host = strings.TrimSpace(strings.Split(xf, ",")[0])
}
if host == "" {
return ""
}
return "https://" + host
}
func requestBaseURL(r *http.Request) string {
return publicHTTPSBaseURL(r)
}
func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required")
return
}
seed := strings.TrimSpace(r.Header.Get("X-EvoBGP-Seed"))
if seed == "" || s.bundleSeedHex == "" || !strings.EqualFold(seed, s.bundleSeedHex) {
writeProblem(w, http.StatusForbidden, "Forbidden", "invalid or missing X-EvoBGP-Seed")
return
}
var body struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
ClientToken string `json:"client_token"`
ClientVersion string `json:"client_version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
name := strings.TrimSpace(body.Name)
tok := strings.TrimSpace(body.ClientToken)
if name == "" || tok == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "name and client_token are required")
return
}
if !strings.HasPrefix(tok, "evobgp_fw_") {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_token must use evobgp_fw_ prefix")
return
}
tenantID, err := s.firewallEnrollTenantID()
if err != nil {
writeInternalError(w, "internal", err)
return
}
hash := authkey.HashToken(tok)
prefix := tok
if len(prefix) > 12 {
prefix = prefix[:12]
}
client, err := s.store.CreateFirewallClient(tenantID, &store.FirewallClientCreate{
Name: name,
Hostname: strings.TrimSpace(body.Hostname),
TokenPrefix: prefix,
TokenHash: hash,
ClientVersion: strings.TrimSpace(body.ClientVersion),
})
if err != nil {
if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusConflict, "Conflict", "client token already enrolled")
return
}
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"client_id": client.ID,
"status": client.Status,
"message": "pending operator approval in EvoBGP UI",
})
}
func (s *Server) firewallEnrollTenantID() (string, error) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid != "" {
return tid, nil
}
ids, err := s.store.ListTenantIDs()
if err != nil {
return "", err
}
if len(ids) == 0 {
return "", errors.New("httpapi: no tenant for firewall enroll")
}
return ids[0], nil
}
func (s *Server) handleFirewallInstallScript(w http.ResponseWriter, r *http.Request) {
s.serveFirewallScript(w, "install.sh")
}
func (s *Server) handleFirewallSyncScript(w http.ResponseWriter, r *http.Request) {
s.serveFirewallScript(w, "evobgp-firewall.sh")
}
func (s *Server) serveFirewallScript(w http.ResponseWriter, name string) {
b, err := readFirewallScript(name)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "script not found")
return
}
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func readFirewallScript(name string) ([]byte, error) {
if b, err := firewallscripts.FS.ReadFile(name); err == nil {
return b, nil
}
candidates := []string{}
if dir := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_SCRIPTS")); dir != "" {
candidates = append(candidates, filepath.Join(dir, name))
}
candidates = append(candidates, filepath.Join("scripts", "firewall", name))
for _, p := range candidates {
b, err := os.ReadFile(p)
if err == nil {
return b, nil
}
}
return nil, os.ErrNotExist
}
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
items, err := s.store.ListFirewallClients(a.TenantID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.GetFirewallClient(a.TenantID, id)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
var patch store.FirewallClientPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
client, err := s.store.UpdateFirewallClient(a.TenantID, id, &patch)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
var clientID *string
if scope == "client" {
cid := strings.TrimSpace(r.URL.Query().Get("client_id"))
if cid == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
return
}
clientID = &cid
}
items, err := s.store.ListFirewallRules(a.TenantID, clientID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body struct {
Scope string `json:"scope"`
ClientID *string `json:"client_id"`
Action string `json:"action"`
CommunityID *string `json:"community_id"`
Comment string `json:"comment"`
Priority *int `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
var clientID *string
if strings.TrimSpace(body.Scope) == "client" {
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
return
}
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
Priority: body.Priority,
Action: body.Action,
CommunityID: body.CommunityID,
Comment: body.Comment,
})
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusCreated, rule)
}
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
var patch store.FirewallRulePatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
rule, err := s.store.UpdateFirewallRule(a.TenantID, id, &patch)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, rule)
}
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body struct {
Scope string `json:"scope"`
ClientID *string `json:"client_id"`
OrderedIDs []string `json:"ordered_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
var clientID *string
if strings.TrimSpace(body.Scope) == "client" {
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required")
return
}
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
if err := s.store.ReorderFirewallRules(a.TenantID, clientID, body.OrderedIDs); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid reorder")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallBlocklist(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
client, err := s.store.GetFirewallClient(a.TenantID, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown firewall client")
return
}
if client.Status != "approved" {
w.Header().Set("Retry-After", "60")
writeProblem(w, http.StatusForbidden, "Forbidden", "client pending approval")
return
}
_ = s.store.TouchFirewallClientLastSeen(client.ID, "cp", clientIP(r), r.UserAgent())
resp, err := s.buildFirewallBlocklist(r.Context(), client)
if err != nil {
if errors.Is(err, errNoFirewallRevision) {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeInternalError(w, "internal", err)
return
}
w.Header().Set("X-EvoBGP-Source", "cp")
w.Header().Set("X-EvoBGP-Revision-ID", resp.RevisionID)
w.Header().Set("X-EvoBGP-Generated-At", resp.GeneratedAt)
w.Header().Set("X-EvoBGP-Rules-Version", resp.RulesVersion)
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for _, p := range resp.Prefixes {
_, _ = w.Write([]byte(p + "\n"))
}
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
var body struct {
Status string `json:"status"`
Error string `json:"error"`
PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"`
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)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
var body struct {
Source string `json:"source"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
src := strings.TrimSpace(body.Source)
if src == "" {
src = "cp"
}
_ = s.store.TouchFirewallClientLastSeen(a.APIKeyID, src, clientIP(r), r.UserAgent())
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.GetFirewallClient(a.TenantID, id)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
resp, err := s.buildFirewallBlocklist(r.Context(), client)
if err != nil {
if errors.Is(err, errNoFirewallRevision) {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, resp)
}
var errNoFirewallRevision = errors.New("httpapi: no firewall revision")
type firewallBlocklistResponse struct {
ClientID string `json:"client_id"`
RevisionID string `json:"revision_id"`
GeneratedAt string `json:"generated_at"`
Source string `json:"source"`
RulesApplied int `json:"rules_applied"`
CommunitiesEvaluated int `json:"communities_evaluated"`
CommunitiesBlocked int `json:"communities_blocked"`
Prefixes []string `json:"prefixes"`
Total int `json:"total"`
Hash string `json:"hash"`
RulesVersion string `json:"-"`
}
func (s *Server) buildFirewallBlocklist(ctx context.Context, client *store.FirewallClient) (*firewallBlocklistResponse, error) {
_ = ctx
revs, _, _ := s.store.ListRevisions(client.TenantID, "", "", 1)
if len(revs) == 0 {
return nil, errNoFirewallRevision
}
rev := revs[0]
prefixesByCommunity, commCount, err := s.loadPrefixesByCommunity(client.TenantID, rev.ID)
if err != nil {
return nil, err
}
rules, err := s.store.ListAllFirewallRulesForClient(client.TenantID, client.ID)
if err != nil {
return nil, err
}
fwRules := storeRulesToFirewall(rules)
blocked := firewall.Evaluate(client.ID, fwRules, prefixesByCommunity)
blockedComm := countBlockedCommunities(client.ID, fwRules, prefixesByCommunity)
hash := prefixListHash(blocked)
return &firewallBlocklistResponse{
ClientID: client.ID,
RevisionID: rev.ID,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Source: "cp",
RulesApplied: len(rules),
CommunitiesEvaluated: commCount,
CommunitiesBlocked: blockedComm,
Prefixes: blocked,
Total: len(blocked),
Hash: hash,
RulesVersion: firewall.RulesVersionHash(fwRules),
}, nil
}
func (s *Server) loadPrefixesByCommunity(tenantID, revisionID string) (map[string][]string, int, error) {
out := make(map[string][]string)
communities := make(map[string]struct{})
cursor := ""
for {
rows, next, more := s.store.ListRevisionPrefixes(tenantID, revisionID, cursor, 5000)
for _, row := range rows {
key := ""
if row.CommunityID != nil {
key = strings.TrimSpace(*row.CommunityID)
}
communities[key] = struct{}{}
out[key] = append(out[key], strings.TrimSpace(row.Prefix))
}
if !more {
break
}
cursor = next
}
return out, len(communities), nil
}
func storeRulesToFirewall(rules []*store.FirewallRule) []firewall.Rule {
out := make([]firewall.Rule, 0, len(rules))
for _, r := range rules {
var cid *string
if r.CommunityID != nil {
v := *r.CommunityID
cid = &v
}
var cl *string
if r.ClientID != nil {
v := *r.ClientID
cl = &v
}
out = append(out, firewall.Rule{
ClientID: cl,
Priority: r.Priority,
Action: r.Action,
CommunityID: cid,
})
}
return out
}
func countBlockedCommunities(clientID string, rules []firewall.Rule, prefixesByCommunity map[string][]string) int {
n := 0
for k := range prefixesByCommunity {
ordered := mergeRulesForCount(clientID, rules)
for _, r := range ordered {
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == k {
if strings.EqualFold(r.Action, "block") {
n++
}
break
}
}
}
return n
}
func mergeRulesForCount(clientID string, rules []firewall.Rule) []firewall.Rule {
var clientRules, tenantRules []firewall.Rule
for _, r := range rules {
if r.ClientID != nil && *r.ClientID == clientID {
clientRules = append(clientRules, r)
continue
}
if r.ClientID == nil {
tenantRules = append(tenantRules, r)
}
}
sort.Slice(clientRules, func(i, j int) bool { return clientRules[i].Priority < clientRules[j].Priority })
sort.Slice(tenantRules, func(i, j int) bool { return tenantRules[i].Priority < tenantRules[j].Priority })
out := append([]firewall.Rule{}, clientRules...)
return append(out, tenantRules...)
}
func prefixListHash(prefixes []string) string {
cp := append([]string(nil), prefixes...)
sort.Strings(cp)
sum := sha256.Sum256([]byte(strings.Join(cp, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}
func clientIP(r *http.Request) string {
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i >= 0 {
return host[:i]
}
return host
}
+282
View File
@@ -0,0 +1,282 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"evobgp/internal/authkey"
"evobgp/internal/store"
)
func TestFirewallEnrollAndBlocklist(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_testtoken123456789012345678901234"
enrollBody := `{"name":"web-01","hostname":"web-01.local","client_token":"` + tok + `","client_version":"test/1"}`
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
reqEnroll.Header.Set("Content-Type", "application/json")
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
respEnroll, err := client.Do(reqEnroll)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respEnroll.Body.Close() }()
if respEnroll.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respEnroll.Body)
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
}
var enroll map[string]any
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
t.Fatal(err)
}
clientID, _ := enroll["client_id"].(string)
if clientID == "" {
t.Fatal("missing client_id")
}
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock.Header.Set("Authorization", "Bearer "+tok)
respBlock, err := client.Do(reqBlock)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock.Body.Close() }()
if respBlock.StatusCode != http.StatusForbidden {
t.Fatalf("pending blocklist want 403 got %d", respBlock.StatusCode)
}
reqApprove, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/clients/"+clientID+"/approve", nil)
reqApprove.Header.Set("Authorization", "Bearer opkey")
respApprove, err := client.Do(reqApprove)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respApprove.Body.Close() }()
if respApprove.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respApprove.Body)
t.Fatalf("approve status=%d body=%s", respApprove.StatusCode, b)
}
_, err = srv.Store().CreateFirewallRule(tenant, nil, &store.FirewallRuleCreate{Action: "accept", Comment: "default"})
if err != nil {
t.Fatal(err)
}
reqBlock2, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock2.Header.Set("Authorization", "Bearer "+tok)
respBlock2, err := client.Do(reqBlock2)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock2.Body.Close() }()
if respBlock2.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respBlock2.Body)
t.Fatalf("blocklist status=%d body=%s", respBlock2.StatusCode, b)
}
var bl map[string]any
if err := json.NewDecoder(respBlock2.Body).Decode(&bl); err != nil {
t.Fatal(err)
}
if total, _ := bl["total"].(float64); total != 0 {
t.Fatalf("accept-only want empty blocklist, total=%v", total)
}
}
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 {
t.Fatal(err)
}
func() {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("%s status=%d body=%s", path, resp.StatusCode, b)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "shellscript") {
t.Fatalf("%s content-type=%q", path, ct)
}
b, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(b), "#!/") {
t.Fatalf("%s missing shebang", path)
}
}()
}
}
func TestFirewallInstallContext(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator,vwkey|"+tenant+"|viewer")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
reqOp, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
reqOp.Header.Set("Authorization", "Bearer opkey")
respOp, err := client.Do(reqOp)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respOp.Body.Close() }()
if respOp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respOp.Body)
t.Fatalf("operator install-context status=%d body=%s", respOp.StatusCode, b)
}
var ctx map[string]any
if err := json.NewDecoder(respOp.Body).Decode(&ctx); err != nil {
t.Fatal(err)
}
if seed, _ := ctx["bundle_seed"].(string); seed != testBundleSeed {
t.Fatalf("bundle_seed=%q want %q", seed, testBundleSeed)
}
if configured, _ := ctx["bundle_seed_configured"].(bool); !configured {
t.Fatal("bundle_seed_configured want true")
}
if url, _ := ctx["suggested_cp_url"].(string); !strings.HasPrefix(url, "https://") {
t.Fatalf("suggested_cp_url=%q want https", url)
}
if url, _ := ctx["install_sh_url"].(string); !strings.HasPrefix(url, "https://") || !strings.HasSuffix(url, "/v1/firewall/install.sh") {
t.Fatalf("install_sh_url=%q", url)
}
reqVw, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
reqVw.Header.Set("Authorization", "Bearer vwkey")
respVw, err := client.Do(reqVw)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respVw.Body.Close() }()
if respVw.StatusCode != http.StatusForbidden {
t.Fatalf("viewer install-context want 403 got %d", respVw.StatusCode)
}
}
func TestFirewallRevokePendingClient(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")
}
reqRevoke, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/clients/"+clientID+"/revoke", nil)
reqRevoke.Header.Set("Authorization", "Bearer opkey")
respRevoke, err := client.Do(reqRevoke)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respRevoke.Body.Close() }()
if respRevoke.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respRevoke.Body)
t.Fatalf("revoke status=%d body=%s", respRevoke.StatusCode, b)
}
got, err := srv.Store().GetFirewallClient(tenant, clientID)
if err != nil {
t.Fatal(err)
}
if got.Status != "revoked" {
t.Fatalf("status=%q want revoked", got.Status)
}
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("revoked blocklist want 403 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))
}
}
+8
View File
@@ -29,6 +29,8 @@ type Server struct {
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
keyResolver *apiKeyResolver
firewallResolver *firewallTokenResolver
bundleSeedHex string
corsOrigins []string
cdnHTTP *http.Client
runtimeLogs *runtimelogs.Service
@@ -74,6 +76,10 @@ func New(opts Options) (*Server, error) {
if err != nil {
return nil, err
}
fwResolver, err := newFirewallTokenResolver(backend)
if err != nil {
return nil, err
}
var pgMon *pgmonitor.Service
var maintCfg *maintenance.ConfigProvider
var maintStats *maintenance.DBStatsProvider
@@ -92,6 +98,8 @@ func New(opts Options) (*Server, error) {
jobs: reg,
bundlePriv: priv,
keyResolver: resolver,
firewallResolver: fwResolver,
bundleSeedHex: strings.TrimSpace(opts.BundleSeedHex),
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
+40 -4
View File
@@ -184,6 +184,10 @@ type Registry struct {
onTerminal func(j *Job)
onEnqueued func(j *Job)
onRunning func(j *Job)
// inflightRefresh counts refresh-kind jobs (module_refresh, tenant_refresh) per tenant that
// have been enqueued but not yet finalized in finishModuleRefreshSuccess. Used for deterministic
// deploy coalescing under tenantRefreshMu (instead of polling job statuses).
inflightRefresh map[string]int
}
type idempoKey struct {
@@ -194,10 +198,11 @@ type idempoKey struct {
func NewRegistry(workerStart func(j *Job)) *Registry {
maxWorkers := registryMaxConcurrentJobs()
return &Registry{
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
workerSem: make(chan struct{}, maxWorkers),
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
workerSem: make(chan struct{}, maxWorkers),
inflightRefresh: make(map[string]int),
}
}
@@ -340,6 +345,9 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
}
r.byID[j.ID] = j
if isRefreshKind(kind) {
r.inflightRefresh[tenantID]++
}
r.pruneTerminalIfOver(maxJobs)
enqueuedHook := r.onEnqueued
workerStart := r.workerStart
@@ -474,6 +482,34 @@ func (r *Registry) CountOtherActiveRefresh(tenantID, excludeJobID string) int {
return n
}
// isRefreshKind reports whether a job kind participates in deploy coalescing.
func isRefreshKind(kind string) bool {
return kind == KindModuleRefresh || kind == KindTenantRefresh
}
// finalizeRefreshCoalesce is called from finishModuleRefreshSuccess under tenantRefreshMu.
// It atomically decrements the per-tenant inflight refresh counter and reports whether the
// caller is the last outstanding refresh for the tenant (and therefore should render+deploy).
//
// Unlike CountOtherActiveRefresh (which polls job statuses and races under -race), this counter
// is incremented in Enqueue under r.mu and decremented here, so the "last one" decision is
// deterministic regardless of how fast each refresh's ingest completes.
func (r *Registry) finalizeRefreshCoalesce(tenantID string) bool {
if r == nil {
return true
}
r.mu.Lock()
defer r.mu.Unlock()
n := r.inflightRefresh[tenantID]
if n <= 1 {
// Last (or already-balanced to zero) — clear the slot and let the caller deploy.
delete(r.inflightRefresh, tenantID)
return true
}
r.inflightRefresh[tenantID] = n - 1
return false
}
func parseCursor(s string, off *int) error {
_, err := fmt.Sscanf(s, "%d", off)
return err
+60 -23
View File
@@ -119,26 +119,7 @@ func (w *Worker) Process(j *Job) {
switch j.Kind {
case KindModuleRefresh:
mid, _ := j.Meta["module_id"].(string)
if strings.TrimSpace(mid) == "" {
j.Fail("missing module_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
w.finishModuleRefreshSuccess(j, mid)
w.runModuleRefresh(j)
case KindTenantRefresh:
w.runTenantRefresh(j)
case KindPeerReconcile:
@@ -295,7 +276,55 @@ func (w *Worker) tenantRefreshMu(tenantID string) *sync.Mutex {
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
// creates one aggregate revision and enqueues a single deploy_apply.
// runModuleRefresh handles a single module_refresh job and guarantees the per-tenant inflight
// slot is released exactly once — even on failure/cancellation before finishModuleRefreshSuccess.
func (w *Worker) runModuleRefresh(j *Job) {
coalesceFinalized := false
defer func() {
if !coalesceFinalized && w != nil && w.Registry != nil {
// Refresh failed/was cancelled before reaching finishModuleRefreshSuccess.
// Decrement the counter under the tenant mutex so the "last one" logic stays sound.
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
w.Registry.finalizeRefreshCoalesce(j.TenantID)
mu.Unlock()
}
}()
mid, _ := j.Meta["module_id"].(string)
if strings.TrimSpace(mid) == "" {
j.Fail("missing module_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
w.finishModuleRefreshSuccess(j, mid)
coalesceFinalized = true
}
func (w *Worker) runTenantRefresh(j *Job) {
coalesceFinalized := false
defer func() {
if !coalesceFinalized && w != nil && w.Registry != nil {
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
w.Registry.finalizeRefreshCoalesce(j.TenantID)
mu.Unlock()
}
}()
moduleIDs := moduleIDsFromJobMeta(j.Meta)
if len(moduleIDs) == 0 {
j.Fail("missing module_ids in job meta")
@@ -318,6 +347,7 @@ func (w *Worker) runTenantRefresh(j *Job) {
}
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
w.finishModuleRefreshSuccess(j, trigger)
coalesceFinalized = true
}
func moduleIDsFromJobMeta(meta map[string]any) []string {
@@ -346,6 +376,9 @@ func moduleIDsFromJobMeta(meta map[string]any) []string {
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
if w == nil || w.Store == nil {
if w != nil && w.Registry != nil {
w.Registry.finalizeRefreshCoalesce(j.TenantID)
}
j.Succeed()
return
}
@@ -354,11 +387,15 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
defer mu.Unlock()
ctx, cancel := j.workContext()
defer cancel()
deferDeploy := false
// Determine whether this is the last outstanding refresh for the tenant. The counter is
// incremented in Enqueue (under r.mu) and decremented here, so the "last one" decision is
// deterministic regardless of ingest timing — unlike the previous status-polling approach
// (CountOtherActiveRefresh) which could race under -race.
isLastRefresh := true
if w.Registry != nil {
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
isLastRefresh = w.Registry.finalizeRefreshCoalesce(j.TenantID)
}
if deferDeploy {
if !isLastRefresh {
j.mergeMeta(map[string]any{
"deploy_apply_deferred": true,
"deploy_apply_defer_reason": "parallel_module_refresh",
+6
View File
@@ -589,6 +589,9 @@ func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error
}
func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
if _, err := uuid.Parse(tenantID); err != nil {
return nil, store.ErrInvalidInput
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
if err != nil {
@@ -610,6 +613,9 @@ func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
}
func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error {
if _, err := uuid.Parse(tenantID); err != nil {
return store.ErrInvalidInput
}
if patch == nil {
return nil
}
+489
View File
@@ -0,0 +1,489 @@
package repository
import (
"context"
"encoding/hex"
"errors"
"strings"
"time"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const firewallClientSelectCols = `
id, name, COALESCE(hostname, ''), token_prefix, status,
last_seen_at, COALESCE(last_seen_at_source, ''), COALESCE(last_seen_ip, ''),
last_apply_at, COALESCE(last_apply_status, ''), COALESCE(last_apply_error, ''),
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0), COALESCE(last_apply_source, ''),
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT `+firewallClientSelectCols+`
FROM firewall_client WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*store.FirewallClient
for rows.Next() {
c, err := scanFirewallClientRow(rows.Scan, tenantID)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (p *Postgres) GetFirewallClient(tenantID, id string) (*store.FirewallClient, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT `+firewallClientSelectCols+`
FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
c, err := scanFirewallClientRow(row.Scan, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return c, nil
}
func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClientCreate) (*store.FirewallClient, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || len(in.TokenHash) != 32 {
return nil, store.ErrInvalidInput
}
id := uuid.NewString()
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
if err != nil {
return nil, err
}
return p.GetFirewallClient(tenantID, id)
}
func (p *Postgres) UpdateFirewallClient(tenantID, id string, patch *store.FirewallClientPatch) (*store.FirewallClient, error) {
cur, err := p.GetFirewallClient(tenantID, id)
if err != nil {
return nil, err
}
if patch == nil {
return nil, store.ErrInvalidInput
}
name := cur.Name
hostname := cur.Hostname
if patch.Name != nil {
name = strings.TrimSpace(*patch.Name)
if name == "" {
return nil, store.ErrInvalidInput
}
}
if patch.Hostname != nil {
hostname = strings.TrimSpace(*patch.Hostname)
}
ctx := context.Background()
_, err = p.pool.Exec(ctx, `UPDATE firewall_client SET name=$3, hostname=$4 WHERE id=$1 AND tenant_id=$2`,
id, tenantID, name, hostname)
if err != nil {
return nil, err
}
return p.GetFirewallClient(tenantID, id)
}
func (p *Postgres) ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*store.FirewallClient, error) {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE firewall_client
SET status='approved', approved_at=now(), approved_by_api_key_id=$3, revoked_at=NULL
WHERE id=$1 AND tenant_id=$2 AND status != 'revoked'`, id, tenantID, nullIfEmpty(approverAPIKeyID))
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, store.ErrNotFound
}
return p.GetFirewallClient(tenantID, id)
}
func (p *Postgres) RevokeFirewallClient(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET status='revoked', revoked_at=now() WHERE id=$1 AND tenant_id=$2`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) DeleteFirewallClient(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `DELETE FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) LookupFirewallClientByTokenHash(hash []byte) (*store.FirewallClient, error) {
if len(hash) != 32 {
return nil, store.ErrNotFound
}
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT tenant_id, `+firewallClientSelectCols+`
FROM firewall_client WHERE token_hash=$1`, hash)
c, err := scanFirewallClientLookupRow(row.Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return c, nil
}
func (p *Postgres) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_seen_at=now(), last_seen_at_source=$2, last_seen_ip=$3,
client_version=COALESCE(NULLIF($4,''), client_version)
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(clientIP), strings.TrimSpace(clientVersion))
return err
}
func (p *Postgres) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
UPDATE firewall_client SET last_apply_at=now(), last_apply_source=$2, last_apply_status=$3,
last_apply_error=$4, last_apply_prefix_count=$5, last_apply_ip_count=$6
WHERE id=$1`, id, strings.TrimSpace(source), strings.TrimSpace(status), strings.TrimSpace(errMsg), prefixCount, ipCount)
return err
}
func (p *Postgres) ListActiveFirewallClientHashes() ([]store.FirewallClientAuthRow, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, tenant_id, token_hash FROM firewall_client WHERE status='approved'`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.FirewallClientAuthRow
for rows.Next() {
var row store.FirewallClientAuthRow
if err := rows.Scan(&row.ID, &row.TenantID, &row.TokenHash); err != nil {
return nil, err
}
if len(row.TokenHash) != 32 {
continue
}
out = append(out, row)
}
return out, rows.Err()
}
func (p *Postgres) ListApprovedFirewallClientsForReplication(tenantID string) ([]store.FirewallClientReplicationRow, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, name, token_hash FROM firewall_client
WHERE tenant_id=$1 AND status='approved' ORDER BY id`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.FirewallClientReplicationRow
for rows.Next() {
var id, name string
var hash []byte
if err := rows.Scan(&id, &name, &hash); err != nil {
return nil, err
}
out = append(out, store.FirewallClientReplicationRow{
ClientID: id,
Name: name,
TokenHashHex: hex.EncodeToString(hash),
})
}
return out, rows.Err()
}
func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*store.FirewallRule, error) {
ctx := context.Background()
var rows pgx.Rows
var err error
if clientID == nil {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
} else {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanFirewallRules(rows, tenantID)
}
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanFirewallRules(rows, tenantID)
}
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE tenant_id=$1
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanFirewallRules(rows, tenantID)
}
func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *store.FirewallRuleCreate) (*store.FirewallRule, error) {
if in == nil || !store.ValidFirewallAction(in.Action) {
return nil, store.ErrInvalidInput
}
if clientID != nil {
if _, err := p.GetFirewallClient(tenantID, *clientID); err != nil {
return nil, err
}
}
priority := 1
if in.Priority != nil && *in.Priority >= 1 {
priority = *in.Priority
} else {
next, err := p.nextFirewallRulePriority(tenantID, clientID)
if err != nil {
return nil, err
}
priority = next
}
id := uuid.NewString()
ctx := context.Background()
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
if err != nil {
return nil, err
}
return p.GetFirewallRule(tenantID, id)
}
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
r, err := scanFirewallRuleRow(row.Scan, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return r, nil
}
func (p *Postgres) UpdateFirewallRule(tenantID, ruleID string, patch *store.FirewallRulePatch) (*store.FirewallRule, error) {
cur, err := p.GetFirewallRule(tenantID, ruleID)
if err != nil {
return nil, err
}
if patch == nil {
return nil, store.ErrInvalidInput
}
action := cur.Action
communityID := cur.CommunityID
comment := cur.Comment
if patch.Action != nil {
if !store.ValidFirewallAction(*patch.Action) {
return nil, store.ErrInvalidInput
}
action = strings.ToLower(strings.TrimSpace(*patch.Action))
}
if patch.ClearCommunity {
communityID = nil
} else if patch.CommunityID != nil {
communityID = patch.CommunityID
}
if patch.Comment != nil {
comment = strings.TrimSpace(*patch.Comment)
}
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE firewall_rule SET action=$3, community_id=$4, comment=$5, updated_at=now()
WHERE id=$1 AND tenant_id=$2`, ruleID, tenantID, action, communityID, comment)
if err != nil {
return nil, err
}
return p.GetFirewallRule(tenantID, ruleID)
}
func (p *Postgres) DeleteFirewallRule(tenantID, ruleID string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `DELETE FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, ruleID, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error {
ctx := context.Background()
tx, err := p.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
for i, id := range orderedIDs {
var execErr error
if clientID == nil {
_, execErr = tx.Exec(ctx, `
UPDATE firewall_rule SET priority=$3, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND client_id IS NULL`, id, tenantID, i+1)
} else {
_, execErr = tx.Exec(ctx, `
UPDATE firewall_rule SET priority=$4, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND client_id=$3`, id, tenantID, *clientID, i+1)
}
if execErr != nil {
return execErr
}
}
return tx.Commit(ctx)
}
func (p *Postgres) nextFirewallRulePriority(tenantID string, clientID *string) (int, error) {
ctx := context.Background()
var max int
var err error
if clientID == nil {
err = p.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(priority),0) FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL`, tenantID).Scan(&max)
} else {
err = p.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(priority),0) FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2`, tenantID, *clientID).Scan(&max)
}
if err != nil {
return 0, err
}
return max + 1, nil
}
func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, error) {
var out []*store.FirewallRule
for rows.Next() {
r, err := scanFirewallRuleRow(rows.Scan, tenantID)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
var r store.FirewallRule
r.TenantID = tenantID
var clientID, communityID *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
r.ClientID = clientID
r.CommunityID = communityID
return &r, nil
}
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
var c store.FirewallClient
c.TenantID = tenantID
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
if err := scan(
&c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var c store.FirewallClient
var approvedBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
if err := scan(
&c.TenantID, &c.ID, &c.Name, &c.Hostname, &c.TokenPrefix, &c.Status,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount), nil
}
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
c.RevokedAt = revoked
if approvedBy != nil {
c.ApprovedByAPIKeyID = *approvedBy
}
if prefixCount != nil {
c.LastApplyPrefixCount = *prefixCount
}
if ipCount != nil {
c.LastApplyIPCount = *ipCount
}
return c
}
func nullIfEmpty(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
@@ -0,0 +1,52 @@
package repository
import (
"context"
"os"
"testing"
"evobgp/internal/authkey"
"evobgp/internal/db"
"evobgp/internal/store"
)
func TestPostgresFirewallClientCreateAndGetIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, true)
if err != nil {
t.Fatal(err)
}
tenant, _, _, _, _ := pg.DemoIDs()
if tenant == "" {
t.Fatal("demo tenant required")
}
tok := "evobgp_fw_pgtest_" + t.Name()
hash := authkey.HashToken(tok)
client, err := pg.CreateFirewallClient(tenant, &store.FirewallClientCreate{
Name: "pg-firewall-test",
Hostname: "test.local",
TokenPrefix: tok[:12],
TokenHash: hash,
ClientVersion: "test/1",
})
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := pg.GetFirewallClient(tenant, client.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != "pg-firewall-test" || got.Status != "pending" {
t.Fatalf("got %+v", got)
}
_ = pg.DeleteFirewallClient(tenant, client.ID)
}
+22
View File
@@ -131,6 +131,28 @@ type Backend interface {
// Runtime log cleanup audit (filesystem ops logged per tenant).
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)
// Firewall blocklist clients and policy rules.
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
CreateFirewallClient(tenantID string, in *FirewallClientCreate) (*FirewallClient, error)
UpdateFirewallClient(tenantID, id string, patch *FirewallClientPatch) (*FirewallClient, error)
ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*FirewallClient, error)
RevokeFirewallClient(tenantID, id string) error
DeleteFirewallClient(tenantID, id string) error
LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)
TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error
TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error
ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)
ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)
ListFirewallRules(tenantID string, clientID *string) ([]*FirewallRule, error)
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
DeleteFirewallRule(tenantID, ruleID string) error
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
+98
View File
@@ -0,0 +1,98 @@
package store
import (
"strings"
"time"
)
// FirewallClient is a Linux blocklist sync client enrolled via seed.
type FirewallClient struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Hostname string `json:"hostname,omitempty"`
TokenPrefix string `json:"token_prefix"`
Status string `json:"status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastSeenAtSource string `json:"last_seen_at_source,omitempty"`
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastApplyAt *time.Time `json:"last_apply_at,omitempty"`
LastApplyStatus string `json:"last_apply_status,omitempty"`
LastApplyError string `json:"last_apply_error,omitempty"`
LastApplyPrefixCount int `json:"last_apply_prefix_count,omitempty"`
LastApplyIPCount int `json:"last_apply_ip_count,omitempty"`
LastApplySource string `json:"last_apply_source,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
type FirewallClientCreate struct {
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
}
// FirewallClientPatch is a partial update for operator edits.
type FirewallClientPatch struct {
Name *string `json:"name,omitempty"`
Hostname *string `json:"hostname,omitempty"`
}
// FirewallClientAuthRow is used to build the in-process firewall token index.
type FirewallClientAuthRow struct {
ID string
TenantID string
TokenHash []byte
}
// FirewallClientReplicationRow is pushed to speaker agents.
type FirewallClientReplicationRow struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
TokenHashHex string `json:"token_hash_hex"`
}
// FirewallRule is one block/accept policy rule.
type FirewallRule struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FirewallRuleCreate is input for creating a rule.
type FirewallRuleCreate struct {
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
}
// FirewallRulePatch is a partial rule update.
type FirewallRulePatch struct {
Action *string `json:"action,omitempty"`
CommunityID *string `json:"community_id,omitempty"`
ClearCommunity bool `json:"-"`
Comment *string `json:"comment,omitempty"`
}
// ValidFirewallAction reports whether action is block or accept.
func ValidFirewallAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) {
case "block", "accept":
return true
default:
return false
}
}
+11
View File
@@ -44,6 +44,9 @@ type Memory struct {
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
firewallClients map[string]*firewallClientRec
firewallRules map[string]*FirewallRule
firewallHashIndex map[string]string // hex hash -> client id
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
@@ -66,6 +69,11 @@ type apiKeyRec struct {
TokenHash []byte
}
type firewallClientRec struct {
FirewallClient
TokenHash []byte
}
type Tenant struct {
ID string
Name string
@@ -143,6 +151,9 @@ func NewMemory() *Memory {
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
firewallClients: make(map[string]*firewallClientRec),
firewallRules: make(map[string]*FirewallRule),
firewallHashIndex: make(map[string]string),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
runtimeLogCleanupAudit: nil,
+463
View File
@@ -0,0 +1,463 @@
package store
import (
"encoding/hex"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) ListFirewallClients(tenantID string) ([]*FirewallClient, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*FirewallClient
for _, rec := range m.firewallClients {
if rec.TenantID == tenantID {
out = append(out, firewallClientCopy(&rec.FirewallClient))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *Memory) GetFirewallClient(tenantID, id string) (*FirewallClient, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) CreateFirewallClient(tenantID string, in *FirewallClientCreate) (*FirewallClient, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || len(in.TokenHash) != 32 {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
hashKey := hex.EncodeToString(in.TokenHash)
if _, dup := m.firewallHashIndex[hashKey]; dup {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
id := uuid.NewString()
rec := &firewallClientRec{
FirewallClient: FirewallClient{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Hostname: strings.TrimSpace(in.Hostname),
TokenPrefix: in.TokenPrefix,
Status: "pending",
ClientVersion: strings.TrimSpace(in.ClientVersion),
CreatedAt: now,
},
TokenHash: append([]byte(nil), in.TokenHash...),
}
m.firewallClients[id] = rec
m.firewallHashIndex[hashKey] = id
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) UpdateFirewallClient(tenantID, id string, patch *FirewallClientPatch) (*FirewallClient, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, ErrInvalidInput
}
rec.Name = n
}
if patch.Hostname != nil {
rec.Hostname = strings.TrimSpace(*patch.Hostname)
}
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) ApproveFirewallClient(tenantID, id, approverAPIKeyID string) (*FirewallClient, error) {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.Status == "revoked" {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
rec.Status = "approved"
rec.ApprovedAt = &now
rec.ApprovedByAPIKeyID = strings.TrimSpace(approverAPIKeyID)
rec.RevokedAt = nil
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) RevokeFirewallClient(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
now := time.Now().UTC()
rec.Status = "revoked"
rec.RevokedAt = &now
return nil
}
func (m *Memory) DeleteFirewallClient(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
hashKey := hex.EncodeToString(rec.TokenHash)
delete(m.firewallHashIndex, hashKey)
delete(m.firewallClients, id)
for rid, rule := range m.firewallRules {
if rule.ClientID != nil && *rule.ClientID == id {
delete(m.firewallRules, rid)
}
}
return nil
}
func (m *Memory) LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error) {
if len(hash) != 32 {
return nil, ErrNotFound
}
m.mu.RLock()
defer m.mu.RUnlock()
id, ok := m.firewallHashIndex[hex.EncodeToString(hash)]
if !ok {
return nil, ErrNotFound
}
rec, ok := m.firewallClients[id]
if !ok {
return nil, ErrNotFound
}
return firewallClientCopy(&rec.FirewallClient), nil
}
func (m *Memory) TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastSeenAt = &now
rec.LastSeenAtSource = strings.TrimSpace(source)
rec.LastSeenIP = strings.TrimSpace(clientIP)
if v := strings.TrimSpace(clientVersion); v != "" {
rec.ClientVersion = v
}
return nil
}
func (m *Memory) TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.firewallClients[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastApplyAt = &now
rec.LastApplySource = strings.TrimSpace(source)
rec.LastApplyStatus = strings.TrimSpace(status)
rec.LastApplyError = strings.TrimSpace(errMsg)
rec.LastApplyPrefixCount = prefixCount
rec.LastApplyIPCount = ipCount
return nil
}
func (m *Memory) ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []FirewallClientAuthRow
for _, rec := range m.firewallClients {
if rec.Status != "approved" {
continue
}
out = append(out, FirewallClientAuthRow{
ID: rec.ID,
TenantID: rec.TenantID,
TokenHash: append([]byte(nil), rec.TokenHash...),
})
}
return out, nil
}
func (m *Memory) ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []FirewallClientReplicationRow
for _, rec := range m.firewallClients {
if rec.TenantID != tenantID || rec.Status != "approved" {
continue
}
out = append(out, FirewallClientReplicationRow{
ClientID: rec.ID,
Name: rec.Name,
TokenHashHex: hex.EncodeToString(rec.TokenHash),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID })
return out, nil
}
func (m *Memory) ListFirewallRules(tenantID string, clientID *string) ([]*FirewallRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*FirewallRule
for _, rule := range m.firewallRules {
if rule.TenantID != tenantID {
continue
}
if clientID == nil {
if rule.ClientID != nil {
continue
}
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
continue
}
out = append(out, firewallRuleCopy(rule))
}
sort.Slice(out, func(i, j int) bool { return out[i].Priority < out[j].Priority })
return out, nil
}
func (m *Memory) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error) {
tenantRules, err := m.ListFirewallRules(tenantID, nil)
if err != nil {
return nil, err
}
cid := clientID
clientRules, err := m.ListFirewallRules(tenantID, &cid)
if err != nil {
return nil, err
}
out := make([]*FirewallRule, 0, len(tenantRules)+len(clientRules))
out = append(out, clientRules...)
out = append(out, tenantRules...)
return out, nil
}
func (m *Memory) ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*FirewallRule
for _, rule := range m.firewallRules {
if rule.TenantID == tenantID {
out = append(out, firewallRuleCopy(rule))
}
}
sort.Slice(out, func(i, j int) bool {
ac, bc := "", ""
if out[i].ClientID != nil {
ac = *out[i].ClientID
}
if out[j].ClientID != nil {
bc = *out[j].ClientID
}
if ac != bc {
return ac < bc
}
return out[i].Priority < out[j].Priority
})
return out, nil
}
func (m *Memory) CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error) {
if in == nil || !ValidFirewallAction(in.Action) {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
if clientID != nil {
if rec, ok := m.firewallClients[*clientID]; !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
}
priority := m.nextFirewallRulePriorityLocked(tenantID, clientID)
if in.Priority != nil && *in.Priority >= 1 {
priority = *in.Priority
}
if m.firewallRulePriorityTakenLocked(tenantID, clientID, priority, "") {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
id := uuid.NewString()
rule := &FirewallRule{
ID: id,
TenantID: tenantID,
ClientID: clientID,
Priority: priority,
Action: strings.ToLower(strings.TrimSpace(in.Action)),
CommunityID: in.CommunityID,
Comment: strings.TrimSpace(in.Comment),
CreatedAt: now,
UpdatedAt: now,
}
m.firewallRules[id] = rule
return firewallRuleCopy(rule), nil
}
func (m *Memory) UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rule, ok := m.firewallRules[ruleID]
if !ok || rule.TenantID != tenantID {
return nil, ErrNotFound
}
if patch.Action != nil {
if !ValidFirewallAction(*patch.Action) {
return nil, ErrInvalidInput
}
rule.Action = strings.ToLower(strings.TrimSpace(*patch.Action))
}
if patch.ClearCommunity {
rule.CommunityID = nil
} else if patch.CommunityID != nil {
rule.CommunityID = patch.CommunityID
}
if patch.Comment != nil {
rule.Comment = strings.TrimSpace(*patch.Comment)
}
rule.UpdatedAt = time.Now().UTC()
return firewallRuleCopy(rule), nil
}
func (m *Memory) DeleteFirewallRule(tenantID, ruleID string) error {
m.mu.Lock()
defer m.mu.Unlock()
rule, ok := m.firewallRules[ruleID]
if !ok || rule.TenantID != tenantID {
return ErrNotFound
}
delete(m.firewallRules, ruleID)
return nil
}
func (m *Memory) ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error {
m.mu.Lock()
defer m.mu.Unlock()
scope := make(map[string]*FirewallRule)
for id, rule := range m.firewallRules {
if rule.TenantID != tenantID {
continue
}
if clientID == nil {
if rule.ClientID != nil {
continue
}
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
continue
}
scope[id] = rule
}
if len(orderedIDs) != len(scope) {
return ErrInvalidInput
}
now := time.Now().UTC()
for i, id := range orderedIDs {
rule, ok := scope[id]
if !ok {
return ErrInvalidInput
}
rule.Priority = i + 1
rule.UpdatedAt = now
}
return nil
}
func (m *Memory) nextFirewallRulePriorityLocked(tenantID string, clientID *string) int {
max := 0
for _, rule := range m.firewallRules {
if rule.TenantID != tenantID {
continue
}
if clientID == nil {
if rule.ClientID != nil {
continue
}
} else if rule.ClientID == nil || *rule.ClientID != *clientID {
continue
}
if rule.Priority > max {
max = rule.Priority
}
}
return max + 1
}
func (m *Memory) firewallRulePriorityTakenLocked(tenantID string, clientID *string, priority int, exceptID string) bool {
for id, rule := range m.firewallRules {
if id == exceptID || rule.TenantID != tenantID || rule.Priority != priority {
continue
}
if clientID == nil {
if rule.ClientID == nil {
return true
}
continue
}
if rule.ClientID != nil && *rule.ClientID == *clientID {
return true
}
}
return false
}
func firewallClientCopy(c *FirewallClient) *FirewallClient {
cp := *c
cp.LastSeenAt = cloneTime(c.LastSeenAt)
cp.LastApplyAt = cloneTime(c.LastApplyAt)
cp.ApprovedAt = cloneTime(c.ApprovedAt)
cp.RevokedAt = cloneTime(c.RevokedAt)
return &cp
}
func firewallRuleCopy(r *FirewallRule) *FirewallRule {
cp := *r
if r.ClientID != nil {
v := *r.ClientID
cp.ClientID = &v
}
if r.CommunityID != nil {
v := *r.CommunityID
cp.CommunityID = &v
}
return &cp
}
func cloneTime(t *time.Time) *time.Time {
if t == nil {
return nil
}
v := *t
return &v
}
+29 -11
View File
@@ -9,17 +9,21 @@ import (
// SpeakerMeta holds well-known keys from bgp_speaker.meta_json.
type SpeakerMeta struct {
AgentDomain string `json:"agent_domain,omitempty"`
AgentSecret string `json:"agent_secret,omitempty"`
AgentPort int `json:"agent_port,omitempty"`
NodeIPv4 string `json:"node_ipv4,omitempty"`
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
LastDispatchError string `json:"last_dispatch_error,omitempty"`
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
SyncStatus string `json:"sync_status,omitempty"`
AgentDomain string `json:"agent_domain,omitempty"`
AgentSecret string `json:"agent_secret,omitempty"`
AgentPort int `json:"agent_port,omitempty"`
NodeIPv4 string `json:"node_ipv4,omitempty"`
BirdBgpSourceIPv4 string `json:"bird_bgp_source_ipv4,omitempty"`
BirdBgpSourceIPv6 string `json:"bird_bgp_source_ipv6,omitempty"`
NodeEnrolledAt string `json:"node_enrolled_at,omitempty"`
LastDispatchAt string `json:"last_dispatch_at,omitempty"`
LastDispatchError string `json:"last_dispatch_error,omitempty"`
LastDispatchStatus string `json:"last_dispatch_status,omitempty"`
SyncStatus string `json:"sync_status,omitempty"`
FirewallFailover bool `json:"firewall_failover,omitempty"`
LastFirewallReplicateAt string `json:"last_firewall_replicate_at,omitempty"`
LastFirewallReplicateStatus string `json:"last_firewall_replicate_status,omitempty"`
LastFirewallReplicateError string `json:"last_firewall_replicate_error,omitempty"`
}
// ParseSpeakerMeta decodes meta_json object; unknown keys are ignored.
@@ -83,6 +87,20 @@ func MergeSpeakerMetaJSON(existing string, patch SpeakerMeta) string {
if patch.SyncStatus != "" {
cur.SyncStatus = patch.SyncStatus
}
if patch.FirewallFailover {
cur.FirewallFailover = true
}
if patch.LastFirewallReplicateAt != "" {
cur.LastFirewallReplicateAt = patch.LastFirewallReplicateAt
}
if patch.LastFirewallReplicateStatus == "ok" {
cur.LastFirewallReplicateError = ""
} else if patch.LastFirewallReplicateError != "" {
cur.LastFirewallReplicateError = patch.LastFirewallReplicateError
}
if patch.LastFirewallReplicateStatus != "" {
cur.LastFirewallReplicateStatus = patch.LastFirewallReplicateStatus
}
return SpeakerMetaJSON(cur)
}
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS firewall_rule;
DROP TABLE IF EXISTS firewall_client;
@@ -0,0 +1,51 @@
CREATE TABLE firewall_client (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
hostname TEXT,
token_prefix TEXT NOT NULL,
token_hash BYTEA NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
enroll_seed_used BOOLEAN NOT NULL DEFAULT TRUE,
last_seen_at TIMESTAMPTZ,
last_seen_at_source TEXT,
last_seen_ip TEXT,
last_apply_at TIMESTAMPTZ,
last_apply_status TEXT,
last_apply_error TEXT,
last_apply_prefix_count INTEGER DEFAULT 0,
last_apply_ip_count INTEGER DEFAULT 0,
last_apply_source TEXT,
client_version TEXT,
settings_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
approved_at TIMESTAMPTZ,
approved_by_api_key_id UUID,
revoked_at TIMESTAMPTZ,
CONSTRAINT firewall_client_status_chk CHECK (status IN ('pending', 'approved', 'revoked')),
CONSTRAINT firewall_client_name_chk CHECK (length(trim(name)) > 0),
CONSTRAINT firewall_client_token_hash_len_chk CHECK (octet_length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_firewall_client_token_hash ON firewall_client (token_hash);
CREATE INDEX idx_firewall_client_tenant_status ON firewall_client (tenant_id, status);
CREATE INDEX idx_firewall_client_last_seen ON firewall_client (last_seen_at DESC) WHERE status = 'approved';
CREATE TABLE firewall_rule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
client_id UUID REFERENCES firewall_client (id) ON DELETE CASCADE,
priority INTEGER NOT NULL,
action TEXT NOT NULL,
community_id UUID REFERENCES bgp_community (id) ON DELETE CASCADE,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT firewall_rule_action_chk CHECK (action IN ('block', 'accept')),
CONSTRAINT firewall_rule_priority_chk CHECK (priority >= 1 AND priority <= 10000),
CONSTRAINT firewall_rule_scope_uniq UNIQUE (tenant_id, client_id, priority)
);
CREATE INDEX idx_firewall_rule_tenant_priority ON firewall_rule (tenant_id, priority);
CREATE INDEX idx_firewall_rule_client ON firewall_rule (client_id) WHERE client_id IS NOT NULL;
CREATE INDEX idx_firewall_rule_tenant_default ON firewall_rule (tenant_id, priority) WHERE client_id IS NULL;
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS firewall_rule;
DROP TABLE IF EXISTS firewall_client;
+51
View File
@@ -0,0 +1,51 @@
CREATE TABLE firewall_client (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
hostname TEXT,
token_prefix TEXT NOT NULL,
token_hash BLOB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
enroll_seed_used INTEGER NOT NULL DEFAULT 1,
last_seen_at TEXT,
last_seen_at_source TEXT,
last_seen_ip TEXT,
last_apply_at TEXT,
last_apply_status TEXT,
last_apply_error TEXT,
last_apply_prefix_count INTEGER DEFAULT 0,
last_apply_ip_count INTEGER DEFAULT 0,
last_apply_source TEXT,
client_version TEXT,
settings_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
approved_at TEXT,
approved_by_api_key_id TEXT,
revoked_at TEXT,
CHECK (status IN ('pending', 'approved', 'revoked')),
CHECK (length(trim(name)) > 0),
CHECK (length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_firewall_client_token_hash ON firewall_client (token_hash);
CREATE INDEX idx_firewall_client_tenant_status ON firewall_client (tenant_id, status);
CREATE INDEX idx_firewall_client_last_seen ON firewall_client (last_seen_at DESC) WHERE status = 'approved';
CREATE TABLE firewall_rule (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
client_id TEXT REFERENCES firewall_client (id) ON DELETE CASCADE,
priority INTEGER NOT NULL,
action TEXT NOT NULL,
community_id TEXT REFERENCES bgp_community (id) ON DELETE CASCADE,
comment TEXT,
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 (action IN ('block', 'accept')),
CHECK (priority >= 1 AND priority <= 10000),
UNIQUE (tenant_id, client_id, priority)
);
CREATE INDEX idx_firewall_rule_tenant_priority ON firewall_rule (tenant_id, priority);
CREATE INDEX idx_firewall_rule_client ON firewall_rule (client_id) WHERE client_id IS NOT NULL;
CREATE INDEX idx_firewall_rule_tenant_default ON firewall_rule (tenant_id, priority) WHERE client_id IS NULL;
+1
View File
@@ -1,6 +1,7 @@
{
"name": "evobgp-release",
"private": true,
"packageManager": "[email protected]",
"scripts": {
"dev": "pnpm --filter @evobgp/web dev",
"build": "pnpm --filter @evobgp/web build",
+4
View File
@@ -111,11 +111,15 @@ function SelectLabel({
function SelectItem({
className,
children,
label,
...props
}: SelectPrimitive.Item.Props) {
const resolvedLabel = label ?? (typeof children === "string" ? children : undefined)
return (
<SelectPrimitive.Item
data-slot="select-item"
label={resolvedLabel}
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
+5 -4
View File
@@ -13,9 +13,10 @@ function Tabs({
return (
<TabsPrimitive.Root
data-slot="tabs"
orientation={orientation}
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
@@ -24,7 +25,7 @@ function Tabs({
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
@@ -58,10 +59,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
set -euo pipefail
CONF_FILE=/etc/evobgp/firewall.conf
LOG_FILE=/var/log/evobgp-firewall.log
STATE_DIR=/var/lib/evobgp-firewall
HASH_FILE="${STATE_DIR}/last_hash"
PREFIX_FILE="${STATE_DIR}/last_prefixes.txt"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
if [[ ! -f "$CONF_FILE" ]]; then
log "missing $CONF_FILE"
exit 1
fi
# shellcheck disable=SC1090
source "$CONF_FILE"
: "${EVOBGP_CP_URL:?}"
: "${CLIENT_TOKEN:?}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
mkdir -p "$STATE_DIR"
BACKEND="${KERNEL_BACKEND:-auto}"
curl_get_blocklist_file() {
local url="$1"
local dest="$2"
local code
code=$(curl -sS -o "$dest" -w "%{http_code}" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Accept: application/json" \
"${url}/v1/firewall/blocklist") || return 1
if [[ "$code" == "403" ]]; then
log "pending approval"
return 2
fi
if [[ "$code" != "200" ]]; then
log "blocklist HTTP $code from $url"
return 1
fi
return 0
}
try_fetch_blocklist() {
local urls=()
if [[ -n "${EVOBGP_FAILOVER_URLS:-}" ]]; then
IFS=',' read -r -a urls <<<"$EVOBGP_FAILOVER_URLS"
else
urls=("${EVOBGP_CP_URL%/}")
fi
local u
for u in "${urls[@]}"; do
u="${u// /}"
u="${u%/}"
local rc=0
curl_get_blocklist_file "$u" "$PREFIX_FILE" || rc=$?
if [[ "$rc" == 2 ]]; then
exit 0
fi
if [[ "$rc" == 0 ]]; then
CP_HIT="$u"
return 0
fi
done
return 1
}
parse_blocklist_file() {
local f="$1"
if [[ ! -s "$f" ]]; then
log "blocklist file empty: $f"
return 1
fi
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
TOTAL=$(jq -r '.total // 0' "$f")
mapfile -t PREFIXES < <(jq -r '.prefixes[]? // empty' "$f")
return 0
fi
if command -v python3 >/dev/null 2>&1; then
local parsed
parsed=$(python3 - "$f" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as fh:
data = json.load(fh)
print(data.get("hash") or "")
print(data.get("total") or 0)
for p in data.get("prefixes") or []:
if p:
print(p)
PY
)
HASH=$(echo "$parsed" | sed -n '1p')
TOTAL=$(echo "$parsed" | sed -n '2p')
mapfile -t PREFIXES < <(echo "$parsed" | sed -n '3,$p')
return 0
fi
HASH=$(grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/')
TOTAL=$(grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' "$f" | head -1 | grep -o '[0-9]*$' || true)
mapfile -t PREFIXES < <(grep -oE '"[0-9]+(\.[0-9]+){3}/[0-9]+"' "$f" | tr -d '"' || true)
return 0
}
nft_join_elements() {
local out="" p
for p in "$@"; do
if [[ -n "$out" ]]; then
out+=", "
fi
out+="$p"
done
printf '%s' "$out"
}
nft_add_v4_chunk() {
local table=$1 name=$2
shift 2
local joined
joined=$(nft_join_elements "$@")
if nft add element "$table" "$name" v4 "{ ${joined} }" 2>>"$LOG_FILE"; then
return 0
fi
log "nft batch add failed (chunk=$#), retrying one-by-one"
local p ok=0
for p in "$@"; do
if nft add element "$table" "$name" v4 "{ $p }" 2>>"$LOG_FILE"; then
ok=$((ok + 1))
fi
done
[[ "$ok" -gt 0 ]]
}
if ! try_fetch_blocklist; then
log "all endpoints failed"
exit 1
fi
HASH=""
TOTAL=0
PREFIXES=()
parse_blocklist_file "$PREFIX_FILE"
log "blocklist bytes=$(wc -c <"$PREFIX_FILE" | tr -d ' ') parsed=${#PREFIXES[@]} api_total=${TOTAL:-0}"
if [[ -z "${TOTAL// }" ]]; then
TOTAL=${#PREFIXES[@]}
fi
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip kernel apply"
exit 0
fi
apply_nft() {
local table=inet
local name=evobgp_blocklist
local v4=()
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
v4+=("$p")
done
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
nft list set "$table" "$name" v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" v4 '{ type ipv4_addr; flags interval; }'
nft flush set "$table" "$name" v4
if ((${#v4[@]})); then
local batch=()
local chunk=64
for p in "${v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft chunk add partial failure"
batch=()
fi
done
if ((${#batch[@]})); then
nft_add_v4_chunk "$table" "$name" "${batch[@]}" || log "nft tail chunk add partial failure"
fi
fi
nft list chain "$table" "$name" input >/dev/null 2>&1 || {
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @v4 drop
}
APPLIED_V4=${#v4[@]}
}
apply_ipset() {
local set=evobgp_blocklist_v4
local n=0
ipset list "$set" >/dev/null 2>&1 || ipset create "$set" hash:net family inet hashsize 4096 maxelem 1048576
ipset flush "$set"
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
ipset add "$set" "$p" -exist
n=$((n + 1))
done
iptables -C INPUT -m set --match-set "$set" src -j DROP 2>/dev/null || \
iptables -I INPUT -m set --match-set "$set" src -j DROP
APPLIED_V4=$n
}
apply_iptables_only() {
iptables -D INPUT -m comment --comment evobgp-block -j DROP 2>/dev/null || true
local n=0
local p
for p in "${PREFIXES[@]}"; do
[[ "$p" == *:* ]] && continue
iptables -C INPUT -s "$p" -j DROP 2>/dev/null || iptables -A INPUT -s "$p" -j DROP
n=$((n + 1))
done
APPLIED_V4=$n
}
clear_block() {
case "$BACKEND" in
nft) nft delete table inet evobgp_blocklist 2>/dev/null || true ;;
ipset)
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
;;
iptables) iptables -S INPUT | grep -i evobgp | sed 's/^-A /-D /' | while read -r line; do iptables $line 2>/dev/null || true; done ;;
esac
APPLIED_V4=0
}
APPLIED_V4=0
if [[ "${TOTAL:-0}" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then
clear_block
log "cleared blocklist (api total=${TOTAL:-0}) backend=$BACKEND"
else
case "$BACKEND" in
nft|auto) if command -v nft >/dev/null 2>&1; then apply_nft; else apply_ipset; fi ;;
ipset) apply_ipset ;;
iptables) apply_iptables_only ;;
*) apply_ipset ;;
esac
log "applied api_total=${TOTAL} ipv4_in_kernel=${APPLIED_V4} from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND hash=${HASH:-empty}"
fi
echo "$HASH" >"$HASH_FILE"
REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":%s,"source":"cp"}' "${TOTAL:-0}" "${APPLIED_V4:-0}")
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$REPORT" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOBGP_CP_URL%/}/v1/firewall/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cp"}' >/dev/null 2>&1 || true
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "evobgp-firewall install: run as root" >&2
exit 1
fi
for cmd in curl bash; do
command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; }
done
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq jq
fi
fi
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
echo "evobgp-firewall install: install jq or python3 for blocklist JSON parsing" >&2
exit 1
fi
: "${EVOBGP_CP_URL:?EVOBGP_CP_URL required}"
: "${EVOBGP_SEED:?EVOBGP_SEED required}"
: "${EVOBGP_CLIENT_NAME:?EVOBGP_CLIENT_NAME required}"
CONF_DIR=/etc/evobgp
CONF_FILE="${CONF_DIR}/firewall.conf"
SYNC_SCRIPT=/usr/local/sbin/evobgp-firewall.sh
if [[ -f "$CONF_FILE" && "${EVOBGP_INSTALL_FORCE:-}" != "1" ]]; then
echo "Already installed ($CONF_FILE). Set EVOBGP_INSTALL_FORCE=1 to reinstall." >&2
exit 1
fi
gen_token() {
if command -v openssl >/dev/null 2>&1; then
echo -n "evobgp_fw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
else
echo -n "evobgp_fw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
fi
}
CLIENT_TOKEN="$(gen_token)"
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
CP_URL="${EVOBGP_CP_URL%/}"
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","client_token":"%s","client_version":"install.sh/1"}' \
"$EVOBGP_CLIENT_NAME" "$HOSTNAME" "$CLIENT_TOKEN")
ENROLL_TMP=$(mktemp)
trap 'rm -f "$ENROLL_TMP"' EXIT
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/firewall/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" ]]; then
echo "evobgp-firewall enroll failed: HTTP ${ENROLL_CODE} from ${CP_URL}/v1/firewall/enroll" >&2
cat "$ENROLL_TMP" >&2
exit 1
fi
RESP=$(cat "$ENROLL_TMP")
CLIENT_ID=""
if command -v jq >/dev/null 2>&1; then
CLIENT_ID=$(echo "$RESP" | jq -r '.client_id')
else
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
fi
mkdir -p "$CONF_DIR"
chmod 700 "$CONF_DIR"
cat >"$CONF_FILE" <<EOF
EVOBGP_CP_URL=${CP_URL}
CLIENT_ID=${CLIENT_ID}
CLIENT_TOKEN=${CLIENT_TOKEN}
CLIENT_NAME=${EVOBGP_CLIENT_NAME}
KERNEL_BACKEND=auto
EOF
chmod 600 "$CONF_FILE"
curl -fsSL "${CP_URL}/v1/firewall/sync-script" -o "$SYNC_SCRIPT"
chmod 755 "$SYNC_SCRIPT"
if command -v nft >/dev/null 2>&1; then
BACKEND=nft
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
BACKEND=ipset
elif command -v iptables >/dev/null 2>&1; then
BACKEND=iptables
else
echo "no supported firewall backend (nft/ipset/iptables)" >&2
exit 1
fi
sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \
echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE"
INTERVAL="${EVOBGP_SYNC_INTERVAL:-5min}"
if command -v systemctl >/dev/null 2>&1; then
cat >/etc/systemd/system/evobgp-firewall.service <<'UNIT'
[Unit]
Description=EvoBGP firewall blocklist sync
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/evobgp-firewall.sh
UNIT
cat >/etc/systemd/system/evobgp-firewall.timer <<UNIT
[Unit]
Description=EvoBGP firewall sync timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=${INTERVAL}
Unit=evobgp-firewall.service
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now evobgp-firewall.timer
echo "Tip: after UI approve, run: rm -f /var/lib/evobgp-firewall/last_hash && ${SYNC_SCRIPT}"
else
echo "*/5 * * * * root ${SYNC_SCRIPT}" >/etc/cron.d/evobgp-firewall
fi
echo "Client ID: ${CLIENT_ID}"
echo "Status: pending — approve in EvoBGP UI → Firewall → Запросы"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
systemctl disable --now evobgp-firewall.timer 2>/dev/null || true
rm -f /etc/cron.d/evobgp-firewall
rm -f /etc/systemd/system/evobgp-firewall.service /etc/systemd/system/evobgp-firewall.timer
systemctl daemon-reload 2>/dev/null || true
nft delete table inet evobgp_blocklist 2>/dev/null || true
ipset destroy evobgp_blocklist_v4 2>/dev/null || true
iptables -D INPUT -m set --match-set evobgp_blocklist_v4 src -j DROP 2>/dev/null || true
rm -f /usr/local/sbin/evobgp-firewall.sh /usr/local/sbin/evobgp-firewall-uninstall.sh
rm -rf /var/lib/evobgp-firewall
if [[ "${EVOBGP_UNINSTALL_REMOVE_CONF:-}" == "1" ]]; then
rm -f /etc/evobgp/firewall.conf
fi
echo "evobgp-firewall uninstalled"