From 7a3eae98b14c5751a149f4759e091f2694e97fbf Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 8 Jul 2026 16:37:27 +0700 Subject: [PATCH] feat(firewall): implement firewall blocklist feature with client management and policy rules 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. --- .codegraph/daemon.pid | 4 +- ...ll_http_blocklist_feature_d64a07b0.plan.md | 1177 ++++++++++++++--- apps/web/src/components/layout/app-shell.tsx | 2 + apps/web/src/queries/firewall.ts | 67 + apps/web/src/routes/_auth/firewall.tsx | 293 ++++ apps/web/src/types/api.ts | 30 + apps/web/tsconfig.tsbuildinfo | 2 +- docs/access.md | 1 + docs/architecture.md | 3 +- docs/firewall.md | 35 + docs/openapi.yaml | 195 +++ docs/remote-speakers.md | 2 + internal/agentserver/firewall.go | 256 ++++ internal/agentserver/server.go | 12 +- internal/firewall/policy.go | 127 ++ internal/firewall/policy_test.go | 75 ++ internal/httpapi/auth.go | 18 + internal/httpapi/firewall_replicate.go | 118 ++ internal/httpapi/firewall_resolver.go | 57 + internal/httpapi/routes.go | 4 + internal/httpapi/routes_firewall.go | 609 +++++++++ internal/httpapi/routes_firewall_test.go | 128 ++ internal/httpapi/server.go | 8 + internal/repository/postgres_firewall.go | 494 +++++++ internal/store/backend.go | 22 + internal/store/firewall_types.go | 98 ++ internal/store/memory.go | 11 + internal/store/memory_firewall.go | 463 +++++++ internal/store/speaker_meta.go | 40 +- migrations/postgres/000027_firewall.down.sql | 2 + migrations/postgres/000027_firewall.up.sql | 51 + migrations/sqlite/000027_firewall.down.sql | 2 + migrations/sqlite/000027_firewall.up.sql | 51 + scripts/firewall/evobgp-firewall.sh | 169 +++ scripts/firewall/install.sh | 110 ++ scripts/firewall/uninstall.sh | 19 + 36 files changed, 4581 insertions(+), 174 deletions(-) create mode 100644 apps/web/src/queries/firewall.ts create mode 100644 apps/web/src/routes/_auth/firewall.tsx create mode 100644 docs/firewall.md create mode 100644 internal/agentserver/firewall.go create mode 100644 internal/firewall/policy.go create mode 100644 internal/firewall/policy_test.go create mode 100644 internal/httpapi/firewall_replicate.go create mode 100644 internal/httpapi/firewall_resolver.go create mode 100644 internal/httpapi/routes_firewall.go create mode 100644 internal/httpapi/routes_firewall_test.go create mode 100644 internal/repository/postgres_firewall.go create mode 100644 internal/store/firewall_types.go create mode 100644 internal/store/memory_firewall.go create mode 100644 migrations/postgres/000027_firewall.down.sql create mode 100644 migrations/postgres/000027_firewall.up.sql create mode 100644 migrations/sqlite/000027_firewall.down.sql create mode 100644 migrations/sqlite/000027_firewall.up.sql create mode 100644 scripts/firewall/evobgp-firewall.sh create mode 100644 scripts/firewall/install.sh create mode 100644 scripts/firewall/uninstall.sh diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index 4d4a3ff..3090f63 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 40060, + "pid": 39884, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da", - "startedAt": 1783395081742 + "startedAt": 1783489774882 } diff --git a/.cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md b/.cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md index 1b2e3d8..f17140c 100644 --- a/.cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md +++ b/.cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md @@ -1,34 +1,40 @@ --- name: Firewall HTTP blocklist feature -overview: "Реализовать подсистему «Firewall Blocklist»: произвольный Linux-сервер через bash-скрипт по cron получает по HTTP список CIDR выбранного BGP community из последней ревизии tenant и блокирует их через ipset/nftables/iptables (автоопределение). Авторизация клиента — Bearer-токен, который клиент генерирует сам; первичная регистрация (enroll) авторизуется через `EVOBGP_BUNDLE_SEED_HEX`; operator подтверждает доверие клиенту в Web UI EvoBGP. На клиенте — только bash-скрипты, без бинарников." +overview: "Реализовать подсистему «Firewall Blocklist»: произвольный Linux-сервер через bash-скрипт по cron получает по HTTP список CIDR выбранных BGP community и блокирует их через ipset/nftables/iptables (автоопределение). Авторизация клиента — Bearer-токен, который клиент генерирует сам; первичная регистрация (enroll) авторизуется через EVOBGP_BUNDLE_SEED_HEX; operator подтверждает доверие клиенту в Web UI EvoBGP. Источник данных — последний подписанный bundle: CP напрямую ИЛИ speaker-agent через тот же DNS-домен (geo/round-robin DNS переключает между ними). Какой набор community блокируется — определяется упорядоченным списком правил block/accept с приоритетом (модель firewall-rules), задаваемых глобально на tenant и/или на конкретном клиенте (клиент переопределяет tenant default). Инвариант: accept alone never block all — только явный block добавляет IP. На клиенте — только bash-скрипты, без бинарников." todos: - id: migrate - content: Создать миграцию 000027_firewall_client (postgres + sqlite, 4 файла) по DEP-03 - status: pending + content: "Создать миграцию 000027 (postgres + sqlite, 4 файла): таблицы firewall_client + firewall_rule (client_id NULL = tenant-default) + down.sql по DEP-03" + status: completed - id: store - content: "Расширить store.Backend: типы FirewallClient*, методы CRUD/Lookup/Touch + реализации в memory.go и postgres_firewall_client.go" - status: pending + content: "Расширить store.Backend: типы FirewallClient/FirewallRule, методы CRUD/Lookup/Touch/RuleOps + EvaluateFirewallPolicy + реализации в memory.go и postgres_firewall.go" + status: completed - id: resolver content: Создать firewall_resolver.go (по образцу api_key_resolver.go) и интегрировать в resolveAuth (auth.go) - status: pending - - id: handlers - content: Написать routes_firewall.go (enroll через seed, blocklist, apply-report, CRUD operator-эндпоинты) + регистрация в registerV1 + status: completed + - id: handlers_cp + content: "Handlers routes_firewall.go на CP: enroll через seed, blocklist (policy-aware), apply-report, CRUD clients + CRUD rules + правила на клиентах" status: pending - id: openapi - content: Обновить docs/openapi.yaml схемами и операциями тега Firewall; npx @redocly/cli lint (TEST-05) - status: pending + content: Обновить docs/openapi.yaml схемами и операциями тега Firewall (вкл. rules); npx @redocly/cli lint (TEST-05) + status: completed - id: webui - content: "Web UI: queries/firewall.ts + route /firewall + sidebar link + shadcn-компоненты; typecheck+lint+build exit 0 (WEB-19)" - status: pending + content: "Web UI: /firewall — FirewallInstallCard (stepper+форма+copy), табы Клиенты/Правила/Запросы, client dialog (статус/политика/превью), sidebar Shield; typecheck+lint+build exit 0 (WEB-19)" + status: completed + - id: agent_failover + content: "Speaker-failover: расширить agentserver.go (handleFirewallBlocklist policy-aware, handleFirewallApplyReport, handleFirewallReplicate с rules) + storage" + status: completed + - id: cp_replicate + content: "CP: при deploy_apply/rule-mutation/client-approve/revoke отправлять на speaker POST /v1/agent/firewall-replicate (clients + rules + prefixes) через nodedispatch pattern" + status: completed - id: bash - content: Написать scripts/firewall/{install.sh,evobgp-firewall.sh,uninstall.sh} с автоопределением backend (nft→ipset→iptables) - status: pending + content: Написать scripts/firewall/{install.sh,evobgp-firewall.sh,uninstall.sh} с автоопределением backend (nft→ipset→iptables) и DNS-failover retry + status: completed - id: docs - content: "Документация: docs/firewall.md (RU) + правки access.md/api.md/architecture.md" - status: pending + content: "Документация: docs/firewall.md (RU, раздел про rules + priorities) + правки access.md/api.md/architecture.md/remote-speakers.md" + status: completed - id: tests - content: "Тесты: табличные store-тесты + handlers-тесты (enroll/approve/blocklist/apply-report); go vet + go test -race + golangci-lint" - status: pending + content: "Тесты: store tests + handlers (enroll/approve/blocklist policy eval edge cases) + agent-failover + EvaluateFirewallPolicy unit tests; go vet + go test -race + golangci-lint" + status: completed isProject: false --- @@ -37,72 +43,445 @@ isProject: false ## Контекст проекта (ключевые факты из анализа) - Авторизация: `internal/httpapi/auth.go` — Bearer → SHA-256 → in-process индекс (`apiKeyResolver`). Роли `viewer/editor/operator/node` через `roleLevel` + `requireAtLeast`/`requireNode`. Чтобы добавить новую роль, нужно синхронно править: `roleLevel`, `store.ValidAPIKeyRole`, CHECK-констрейнт `api_key_role_chk` новой миграцией (DEP-03 — пары postgres+sqlite). -- Подпись бандлов: `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) → `ed25519.NewKeyFromSeed` в `internal/httpapi/server.go:60-71`. Seed уже доступен в `Server` через `opts.BundleSeedHex`. -- Префиксы в ревизиях: `store.Backend.ListRevisionPrefixes(tenantID, revID, cursor, limit)` возвращает `[]PrefixRow{Prefix, CommunityID, Source}`. `ListRevisions(tenantID, moduleID, cursor, limit)` даёт последнюю ревизию. `prefix_snapshot_row.community_id` — FK на `bgp_community`. -- Токены API: `internal/authkey/token.go` — `GenerateToken()` (`evobgp_<32 b64url>`), `HashToken()` (SHA-256 → 32 байта), `Prefix()`. В БД — `api_key.token_hash BYTEA` + UNIQUE-индекс. -- Миграции: 26 пар, последняя `000026_runtime_log_cleanup_audit`. Новая — `000027_firewall_client` для postgres+sqlite, синхронно. -- Web UI: TanStack Router file-based (`apps/web/src/routes/_auth/*.tsx`), TanStack Query (`apps/web/src/queries/*.ts`), shadcn/ui + ReUI. +- Подпись бандлов: `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) → `ed25519.NewKeyFromSeed` в `internal/httpapi/server.go:60-71`. Seed уже доступен в `Server` через `opts.BundleSeedHex`. Подпись — Ed25519 над `manifest.json` (`internal/bundle/pack.go:38-78`). +- Префиксы в ревизиях: `store.Backend.ListRevisionPrefixes(tenantID, revID, cursor, limit)` возвращает `[]PrefixRow{Prefix, CommunityID, Source}`. `prefix_snapshot_row.community_id` — FK на `bgp_community`. +- BGP Community: таблица `bgp_community` (миграция 000001) — `id UUID`, `tenant_id`, `name`, `kind`, `value_json JSONB`. Tenant-scoped. Используется в `module.default_community_id`, `module_*_entry.community_id`, `prefix_snapshot_row.community_id`. В UI — справочник на странице `/directories`. +- Speaker-agent: `internal/agentserver/server.go` — уже запускает HTTP на `:8443` с авторизацией `EVOBGP_AGENT_SECRET`. Speaker хранит last-applied revision в памяти. +- Panel→Node dispatch: `internal/nodedispatch` (`WakeSpeaker`) — CP шлёт `POST https:///v1/agent/sync` после `deploy_apply`. Логика доступности: `store.SpeakerNeedsRemoteDispatch` (`role != "master"` + `agent_domain` + `agent_secret`). +- Токены API: `internal/authkey/token.go` — `GenerateToken()` (`evobgp_<32 b64url>`), `HashToken()` (SHA-256 → 32 байта), `Prefix()`. +- Миграции: 26 пар, последняя `000026_runtime_log_cleanup_audit`. Новая — `000027` для postgres+sqlite, синхронно. +- Web UI: TanStack Router file-based, TanStack Query, shadcn/ui + ReUI. -## Поток данных +## Архитектура: один домен, два источника, policy-aware ```mermaid -flowchart LR +flowchart TB + DNS["DNS: blocklist.example.com
round-robin или geo-DNS"] + DNS -.-> CP_IP[CP IP] + DNS -.-> SP1_IP[Speaker-1 IP] + DNS -.-> SP2_IP[Speaker-2 IP] subgraph client [Linux-клиент только bash] - Installer[install.sh curl-bash] - Conf["/etc/evobgp/firewall.conf - client_id, token, base_url, - community_id"] - Sync[evobgp-firewall.sh по cron/systemd] + Sync[evobgp-firewall.sh по cron] Kernel[(ipset/nftables/iptables)] end - subgraph cp [Control Plane EvoBGP] + subgraph cp [Control Plane] API[evobgp-api/all] PG[(PostgreSQL)] UI[Web UI /firewall] end - Installer -->|1 POST /v1/firewall/enroll - X-EvoBGP-Seed, client_token| API + subgraph sp [Speaker-agent] + Agent[agentserver :8443] + State["/var/lib/evobgp-agent/firewall-state.json"] + end + UI -->|"rules editor
block/accept ordered"| API + UI -->|"clients + overrides"| API API --> PG - Installer --> Conf - Operator -->|2 Approve в UI| UI - UI -->|PATCH /firewall/clients/{id}/approve| API - Sync -->|3 GET /v1/firewall/blocklist - Bearer client_token| API - API --> PG - API -->|CIDR list по community_id| Sync + Sync -->|"GET /v1/firewall/blocklist
Bearer client_token"| DNS + DNS --> CP_IP & SP1_IP & SP2_IP + CP_IP --> API + SP1_IP --> Agent + SP2_IP --> Agent + API -->|"POST /v1/agent/firewall-replicate
(clients + rules + prefixes)"| Agent + Agent --> State + Agent -->|"evaluated CIDR list"| Sync Sync --> Kernel - Sync -->|report last_apply| API + Sync -->|"POST /v1/firewall/apply-report"| DNS ``` -## Шаг 1 — Миграция БД `000027_firewall_client` +**Ключевые свойства:** + +- **Один API-контракт** `GET /v1/firewall/blocklist` — на CP и на speaker идентичный. В ответе уже **вычисленный** список CIDR после применения policy rules. Клиент не знает про правила — он просто получает плоский список CIDR. +- **Speaker хранит реплицированное состояние**: token_hashes клиентов + **список rules** + prefixes по community. Вычисление policy происходит и на CP, и на speaker (детерминированный алгоритм). +- **CP проталкивает (push)** обновления на speaker при `deploy_apply`/`rule mutation`/`approve`/`revoke` через существующий `nodedispatch.WakeSpeaker` pattern — endpoint `POST /v1/agent/firewall-replicate`. +- Если speaker не имеет свежих данных — отдаёт последние кешированные с `X-EvoBGP-Stale: true` и `Age: `. + +## Модель policy: упорядоченные правила `block` / `accept` + +### Терминология (важно для UI и docs) + +| Действие | Значение для трафика | Попадает в blocklist (iptables DROP) | +|---|---|---| +| **`block`** | Заблокировать IP этой community | **Да** — CIDR добавляются в kernel | +| **`accept`** | Разрешить (не блокировать) | **Нет** — CIDR исключаются из blocklist | + +**Инвариант (требование пользователя):** правила `accept` **никогда сами по себе не создают «block all»**. +Только явные правила `block` добавляют IP в список блокировки. Если в политике сервера нет ни одного сработавшего `block` — итоговый blocklist **пуст**. + +### Структура правила + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | PK | +| `tenant_id` | UUID | FK tenant (для tenant-default правил) | +| `client_id` | UUID NULL | FK firewall_client; NULL = tenant-default; NOT NULL = переопределение для конкретного сервера | +| `priority` | INTEGER | Порядок (1 = высший). Уникален в рамках scope | +| `action` | TEXT | `block` или `accept` | +| `community_id` | UUID NULL | FK bgp_community; NULL = wildcard «все community» | +| `comment` | TEXT | Комментарий для UI | + +Миграция: `CHECK (action IN ('block','accept'))`. + +### Алгоритм вычисления (`EvaluateFirewallPolicy`) + +Для каждой community, у которой есть префиксы в последней ревизии: + +``` +function should_block_community(client, community_id): + rules = merge_sorted( + tenant_default_rules[client.tenant_id], # ниже по приоритету + client_override_rules[client.id] # выше — переопределяет tenant + ) // client scope first, then priority asc within scope + + for rule in rules: + if rule.community_id is NULL or rule.community_id == community_id: + return rule.action == "block" # первое совпавшее выигрывает + + return false # default: accept — НЕ блокировать (не block all) +``` + +Итоговый blocklist = объединение префиксов всех community, для которых `should_block_community == true`. + +**Семантика:** + +- **Default = accept (не блокировать):** если ни одно правило не совпало — community **не** попадает в blocklist. +- **`block`** — явно добавить префиксы community в blocklist. +- **`accept`** — явно **исключить** community из blocklist (даже если ниже по приоритету есть `block` на wildcard). +- **Client overrides tenant:** правила конкретного сервера проверяются **раньше** tenant-default (client scope first). +- **Wildcard `community_id IS NULL`:** правило на все community; `block` + wildcard = «блокировать всё, что есть в ревизии» — **только если operator явно создал такое правило**; UI показывает предупреждение «опасное правило». + +### Примеры + +**Пример A — только block (типичный сценарий)** + +Tenant-default: + +1. `block` community=`botnet-list` +2. `block` community=`spam-asn` + +Результат на всех серверах: блокируются только эти две community. Остальные — не трогаются. + +**Пример B — block all + исключения (accept не создаёт block all)** + +Tenant-default: + +1. `block` community=* (wildcard — блокировать все community из ревизии) + +Client override для `edge-router-01`: + +1. `accept` community=`trusted-cdn` (исключение для этого сервера) + +Результат на `edge-router-01`: блокируется всё **кроме** `trusted-cdn`. +На других серверах без override: блокируется всё (из-за явного wildcard `block`, не из-за `accept`). + +**Пример C — только accept (инвариант)** + +Tenant-default: + +1. `accept` community=* + +Результат: **пустой blocklist** на всех серверах. Accept alone never blocks. + +**Пример D — per-server разная политика** + +| Сервер | Правила (client override) | Блокируется | +|---|---|---| +| `web-01` | `block` botnet-list | только botnet-list | +| `db-01` | `block` botnet-list; `block` spam-asn | botnet + spam | +| `monitor-01` | `accept` * (override tenant block) | ничего (если tenant только block rules — monitor отменяет через accept *) | + +## Управление: что блокировать на каком сервере + +### Два уровня политики + +```mermaid +flowchart TB + subgraph tenant [Tenant-default — все серверы] + TR1["block botnet-list"] + TR2["block spam-asn"] + end + subgraph srvA [Сервер web-01 — overrides] + OR1["accept trusted-cdn"] + end + subgraph srvB [Сервер db-01 — overrides] + OR2["block extra-malware"] + end + tenant --> srvA + tenant --> srvB + EvalA["Effective: block botnet, spam
EXCEPT trusted-cdn"] + EvalB["Effective: block botnet, spam,
extra-malware"] + srvA --> EvalA + srvB --> EvalB +``` + +1. **Tenant-default** (`/firewall` → таб «Правила по умолчанию»): базовая политика для **всех** approved-клиентов tenant. +2. **Per-server overrides** (диалог клиента → вкладка «Политика сервера»): только для выбранного `firewall_client`; имеют **более высокий** приоритет, чем tenant-default. + +Operator **не** указывает community при enroll — политика задаётся **только** через правила в UI (до или после approve). + +### UI-поток управления (operator) + +| Шаг | Где в UI | Действие | +|---|---|---| +| 1 | `/directories` | Создать BGP community (если ещё нет) | +| 2 | `/modules` + refresh | Убедиться, что префиксы community материализованы в ревизии | +| 3 | `/firewall` → «Правила по умолчанию» | Добавить tenant `block`/`accept` правила (drag-and-drop порядок) | +| 4 | `/firewall` → «Клиенты» → Install | Скопировать one-liner, выполнить на целевом Linux-сервере | +| 5 | `/firewall` → «Запросы» | Approve pending-клиента | +| 6 | Диалог клиента → «Политика» | При необходимости добавить per-server overrides | +| 7 | Диалог клиента → «Превью» | `GET /v1/firewall/clients/{id}/preview` — effective blocklist без применения на сервере | +| 8 | Мониторинг | `last_seen_at`, `last_apply_at`, `last_apply_source`, `prefix_count` | + +### API для управления политикой + +- Tenant rules: `GET/POST/PATCH/DELETE /v1/firewall/rules?scope=tenant` +- Server overrides: `GET/POST/PATCH/DELETE /v1/firewall/rules?scope=client&client_id={id}` +- Reorder: `POST /v1/firewall/rules:reorder` +- Effective preview: `GET /v1/firewall/clients/{id}/preview` (operator, viewer+) + +После любой мутации правил — `replicateFirewallStateToSpeakers(tenantID)`. + +## Установка скрипта на Linux-сервер + +### Принципы установки + +Установка следует той же философии, что и remote-speaker ([docs/remote-speakers.md](docs/remote-speakers.md)): **минимум доверия на стороне CP, максимум автономии на стороне сервера**. + +| Принцип | Реализация | +|---|---| +| **Только bash, без бинарников** | На целевом сервере нет `evobgp-firewall` binary; `install.sh` + `evobgp-firewall.sh` скачиваются с CP по HTTP | +| **One-liner `curl \| bash`** | Operator копирует одну команду из UI; не нужен отдельный пакет/deb/rpm | +| **Токен генерирует сервер** | `evobgp_fw_*` создаётся локально в `install.sh`; CP получает только hash; plaintext токен **никогда** не показывается в UI | +| **Seed — только для enroll** | `EVOBGP_BUNDLE_SEED_HEX` передаётся один раз в `X-EvoBGP-Seed`; **не** записывается в `firewall.conf` на сервере | +| **Pending → approve** | После enroll клиент в статусе `pending`; blocklist не выдаётся (`403`) до явного approve operator'ом — защита от несанкционированных enroll | +| **Политика не на сервере** | Сервер не знает про community/rules; только тянет готовый CIDR-list. Все `block`/`accept` — в EvoBGP UI | +| **Идемпотентность install** | Повторный запуск install на том же хосте: если `firewall.conf` уже есть — предложить переустановку (`--force`) или exit с инструкцией, не дублировать enroll | +| **Автономный sync** | После install серверу нужен только исходящий HTTPS к API-домену; operator не участвует в каждом обновлении | +| **Root на время install** | Kernel backend (nft/ipset/iptables) требует root; timer/cron тоже от root | +| **Откат без CP** | `uninstall.sh` снимает rules/timer локально, даже если CP недоступен | + +```mermaid +flowchart TB + subgraph trust [Граница доверия] + Seed["X-EvoBGP-Seed
(один раз)"] + Token["Client token
(генерирует сервер)"] + Approve["Operator approve
(UI)"] + end + subgraph server [Целевой Linux-сервер] + Install[install.sh] + Conf[firewall.conf] + Timer[systemd timer] + Sync[evobgp-firewall.sh] + Kernel[kernel DROP] + end + subgraph cp [Control Plane] + DB[(PostgreSQL)] + Rules[Policy rules UI] + end + Seed --> Install + Token --> Install + Install --> Conf + Install --> Timer + Timer --> Sync + Sync -->|"Bearer token"| cp + Approve --> DB + Rules --> DB + cp -->|"evaluated CIDR"| Sync + Sync --> Kernel +``` + +**Что operator делает vs что делает скрипт:** + +| Действие | Operator (UI + SSH) | Скрипт (автоматически) | +|---|---|---| +| Настроить block/accept | Да | Нет | +| Скопировать install-команду | Да | Нет | +| Выполнить на сервере | Да (SSH) | Да (после pipe в bash) | +| Сгенерировать токен | Нет | Да | +| Enroll | Нет | Да | +| Approve клиента | Да | Нет | +| Sync blocklist каждые 5 мин | Нет | Да | +| Применить iptables/nft | Нет | Да | + +### Роли + +| Роль | Кто | Что делает | +|---|---|---| +| Operator (EvoBGP UI) | Человек с ролью `operator` | Копирует install-команду, настраивает правила, approve клиента | +| Root на целевом сервере | Админ Linux VPS | Запускает one-liner (curl \| bash), cron/systemd работает от root | + +### Предварительные требования на сервере + +- Linux с **root** (или sudo без пароля для install) +- `curl`, `bash` (обычно есть) +- Один из: `nft`, `ipset`+`iptables`, или `iptables` (автоопределение) +- Исходящий HTTPS к домену EvoBGP (`blocklist.example.com` или CP URL) +- `EVOBGP_BUNDLE_SEED_HEX` известен operator (для первичного enroll; **не** сохраняется на сервере после install) +- Опционально: `jq` (если нет — скрипт запрашивает `Accept: text/plain`) + +### Пошаговая установка + +```mermaid +sequenceDiagram + participant Op as Operator UI + participant Srv as Linux-сервер root + participant CP as EvoBGP API + Op->>Op: 1. Настроить tenant rules (block/accept) + Op->>Srv: 2. SSH + выполнить curl one-liner + Srv->>CP: 3. POST /v1/firewall/enroll (X-EvoBGP-Seed) + CP-->>Srv: 201 pending + Srv->>CP: 4. GET /v1/firewall/sync-script + Srv->>Srv: 5. Записать файлы + systemd timer + Op->>CP: 6. Approve клиента в UI + Note over Srv: 7. Timer каждые 5 мин: sync blocklist +``` + +**Шаг 1 — Operator готовит команду в UI** + +На `/firewall` → блок «Установка» (или в диалоге помощи): + +```bash +curl -fsSL https://blocklist.example.com/v1/firewall/install.sh | \ + EVOBGP_CP_URL=https://blocklist.example.com \ + EVOBGP_SEED= \ + EVOBGP_CLIENT_NAME="web-01" \ + bash +``` + +Переменные: + +| Env | Обязательно | Описание | +|---|---|---| +| `EVOBGP_CP_URL` | да | Base URL API (тот же DNS-домен для failover) | +| `EVOBGP_SEED` | да | `EVOBGP_BUNDLE_SEED_HEX` control plane (только при enroll) | +| `EVOBGP_CLIENT_NAME` | да | Имя сервера в UI (латиница) | +| `EVOBGP_SYNC_INTERVAL` | нет | Интервал timer, default `5min` | +| `EVOBGP_FAILOVER_URLS` | нет | Явный список URL через запятую (если нет DNS round-robin) | + +**Шаг 2 — `install.sh` на сервере (автоматически)** + +1. Проверка root (`id -u == 0`). +2. Проверка зависимостей (`curl`, `bash`; warning если нет `jq`). +3. Генерация токена: `CLIENT_TOKEN="evobgp_fw_$(head -c 32 /dev/urandom | basenc --base64url 2>/dev/null || openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"`. +4. `POST ${EVOBGP_CP_URL}/v1/firewall/enroll` с заголовком `X-EvoBGP-Seed: ${EVOBGP_SEED}`, body `{name, hostname: $(hostname -f), client_token, client_version}`. +5. При `201` — сохранить конфиг (права `0600`): + +``` +# /etc/evobgp/firewall.conf +EVOBGP_CP_URL=https://blocklist.example.com +CLIENT_ID= +CLIENT_TOKEN=evobgp_fw_... +CLIENT_NAME=web-01 +KERNEL_BACKEND=ipset # auto-detected: nft|ipset|iptables +``` + +6. Скачать `GET ${EVOBGP_CP_URL}/v1/firewall/sync-script` → `/usr/local/sbin/evobgp-firewall.sh` (`chmod 755`). +7. Скачать `uninstall.sh` → `/usr/local/sbin/evobgp-firewall-uninstall.sh` (опционально). +8. Автоопределение backend: `command -v nft` → `nft`; иначе `ipset`+`iptables`; иначе `iptables` only. +9. Detect UFW: если `ufw status` active → warning в лог + docs (правила через raw iptables/ipset). +10. Установка **systemd** (предпочтительно): + +- `/etc/systemd/system/evobgp-firewall.service` — `Type=oneshot`, `ExecStart=/usr/local/sbin/evobgp-firewall.sh` +- `/etc/systemd/system/evobgp-firewall.timer` — `OnBootSec=2min`, `OnUnitActiveSec=5min` +- `systemctl daemon-reload && systemctl enable --now evobgp-firewall.timer` + +11. Fallback без systemd: строка в `/etc/cron.d/evobgp-firewall` → `*/5 * * * * root /usr/local/sbin/evobgp-firewall.sh`. +12. Первый запуск sync (опционально): может вернуть `403 pending` — нормально до approve. +13. Вывод operator: `Client ID: . Ожидает approve в EvoBGP UI → Firewall → Запросы.` + +**Шаг 3 — Operator approve в UI** + +`/firewall` → «Запросы» → Approve → CP реплицирует state на speakers → resolver reload → клиент при следующем timer получает blocklist. + +**Шаг 4 — Проверка** + +- UI: `last_seen_at` обновился, `last_apply_status=ok`, `prefix_count > 0` (если есть block rules). +- На сервере: `ipset list evobgp_blocklist_v4` / `nft list table inet evobgp_blocklist` / `iptables -L INPUT -n`. +- Лог: `/var/log/evobgp-firewall.log`. + +### Файлы на сервере после установки + +| Путь | Назначение | +|---|---| +| `/etc/evobgp/firewall.conf` | URL, token, client_id (chmod 600) | +| `/usr/local/sbin/evobgp-firewall.sh` | Основной sync-скрипт | +| `/usr/local/sbin/evobgp-firewall-uninstall.sh` | Удаление | +| `/var/lib/evobgp-firewall/last_hash` | Кеш hash для skip повторного apply | +| `/var/log/evobgp-firewall.log` | Лог синхронизаций | +| `/etc/systemd/system/evobgp-firewall.{service,timer}` | Автозапуск | + +### Удаление + +```bash +/usr/local/sbin/evobgp-firewall-uninstall.sh +# или: curl -fsSL .../uninstall.sh | bash +``` + +Снимает timer/cron, удаляет ipset/nft/iptables rules, опционально `/etc/evobgp/`. + +## Поток данных (последовательность) + +```mermaid +sequenceDiagram + participant Op as Operator UI + participant Cl as Linux-клиент + participant DNS + participant CP as CP API + participant Sp as Speaker-agent + Op->>CP: 1. POST /v1/firewall/rules (создание tenant-default rules) + Op->>CP: 2. POST /v1/firewall/rules (scope=client, client overrides) + CP->>Sp: 3. POST /v1/agent/firewall-replicate (rules + clients + prefixes) + Sp-->>CP: 200 ok + Cl->>CP: 4. POST /v1/firewall/enroll (X-EvoBGP-Seed, client_token) + CP-->>Cl: 201 {client_id, status: pending} + Op->>CP: 5. POST /v1/firewall/clients/{id}/approve + CP->>Sp: 6. POST /v1/agent/firewall-replicate + loop каждые 5 минут + Cl->>DNS: GET /v1/firewall/blocklist (Bearer client_token) + DNS-->>Cl: IP CP или Speaker + alt CP отвечает + Cl->>CP: запрос + Note over CP: EvaluateFirewallPolicy(client, prefixes) + CP-->>Cl: 200 JSON (X-EvoBGP-Source: cp) + else CP недоступен + Cl->>DNS: retry + Cl->>Sp: запрос + Note over Sp: EvaluateFirewallPolicy(client, prefixes)
из firewall-state.json + Sp-->>Cl: 200 JSON (X-EvoBGP-Source: speaker) + end + Cl->>DNS: POST /v1/firewall/apply-report + Note over Cl: atomic swap в kernel + end +``` + +## Шаг 1 — Миграция БД `000027` Новые файлы (DEP-03 — пары для postgres+sqlite): -- `migrations/postgres/000027_firewall_client.up.sql` -- `migrations/postgres/000027_firewall_client.down.sql` -- `migrations/sqlite/000027_firewall_client.up.sql` -- `migrations/sqlite/000027_firewall_client.down.sql` +- `migrations/postgres/000027_firewall.up.sql` / `.down.sql` +- `migrations/sqlite/000027_firewall.up.sql` / `.down.sql` -Схема `firewall_client` (Postgres): +Схема (Postgres): ```sql +-- Firewall clients (token-аутентификация, статусы, метрика apply) 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, - community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL, 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(), @@ -116,26 +495,47 @@ CREATE TABLE firewall_client ( 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'; + +-- Firewall rules: tenant-default (client_id IS NULL) + client overrides (client_id NOT NULL) +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), + -- client_id NULL = tenant-default scope; NOT NULL = client override scope + -- priority уникален ВНУТРИ scope + 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; ``` -SQLite — то же с поправкой на типы (`TEXT`/`BLOB`, `length(token_hash) = 32`). +`UNIQUE (tenant_id, client_id, priority)` — Postgres считает `NULL != NULL`, но `client_id IS NULL` у tenant-default уникален по логике «одна строка на tenant». Для гарантии на уровне приложения — проверять в store, что для tenant-default не может быть двух правил с одинаковым priority (так как NULL в UNIQUE не конфликтует). -Миграция НЕ трогает `api_key` — `firewall_client` — отдельная таблица (как у speakers своя `agent_secret`, но здесь переиспользуется только формат токена `authkey`). +SQLite — то же с поправкой на типы (`TEXT`/`BLOB`/`INTEGER`). ## Шаг 2 — Роль `firewall` Изолированная роль (level 0, как `node`): -- `internal/httpapi/auth.go`: добавить функцию `requireFirewall(w, a)` по аналогии с `requireNode` (`auth.go:137-143`). В `roleLevel` `firewall` явно не добавлять (остаётся `default → 0`), чтобы `requireAtLeast` её отбрасывал. -- `internal/store/backend.go:305-313` — добавить `firewall` в `ValidAPIKeyRole` НЕ нужно (токены firewall-клиентов живут в отдельной таблице, не в `api_key`). Но если хотим единую модель — рассмотреть расширение `api_key`. Решение: **отдельная таблица**, чтобы не дублировать резолвер. Новый resolver `firewallTokenResolver` по аналогии с `apiKeyResolver`, но с тремя статусами. -- `internal/httpapi/auth.go` `resolveAuth` — расширить: если `apiKeyResolver.Lookup` не нашёл, попробовать `firewallResolver.Lookup`. Возвращать `Auth{Role: "firewall", APIKeyID: , TenantID: ...}`. +- `internal/httpapi/auth.go`: добавить функцию `requireFirewall(w, a)` по аналогии с `requireNode`. В `roleLevel` `firewall` не добавлять (остаётся `default → 0`). +- `internal/store/backend.go` `ValidAPIKeyRole` — `firewall` НЕ добавляем (токены firewall-клиентов в отдельной таблице). +- `resolveAuth` — если `apiKeyResolver.Lookup` не нашёл, попробовать `firewallResolver.Lookup`. Возвращать `Auth{Role: "firewall", APIKeyID: , TenantID: ...}`. ## Шаг 3 — Store слой В `internal/store/backend.go`: -- Структуры `FirewallClient`, `FirewallClientPatch`, `FirewallClientWithSecret` (для issue/rotate). -- Методы в `Backend`: +- Структуры: `FirewallClient`, `FirewallClientPatch`, `FirewallClientCreate`, `FirewallRule`, `FirewallRuleCreate`, `FirewallRulePatch`. +- Методы клиента (аналогично `api_key`): - `ListFirewallClients(tenantID string) ([]*FirewallClient, error)` - `GetFirewallClient(tenantID, id string) (*FirewallClient, error)` - `CreateFirewallClient(tenantID string, in *FirewallClientCreate) (*FirewallClient, error)` @@ -144,74 +544,127 @@ SQLite — то же с поправкой на типы (`TEXT`/`BLOB`, `length - `RevokeFirewallClient(tenantID, id string) error` - `DeleteFirewallClient(tenantID, id string) error` - `LookupFirewallClientByTokenHash(hash []byte) (*FirewallClient, error)` - - `TouchFirewallClientLastSeen(id, clientIP, clientVersion string) error` - - `TouchFirewallClientLastApply(id, status, errMsg string, prefixCount, ipCount int) error` + - `TouchFirewallClientLastSeen(id, source, clientIP, clientVersion string) error` + - `TouchFirewallClientLastApply(id, source, status, errMsg string, prefixCount, ipCount int) error` - `ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)` +- Методы правил: + - `ListFirewallRules(tenantID string, clientID *string) ([]*FirewallRule, error)` — если `clientID == nil` → tenant-default; иначе — overrides клиента. + - `ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)` — объединение tenant-default + client overrides (для репликации на speaker и для UI preview). + - `CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)` — приоритет автоинкрементом: если не задан — `MAX+1`. + - `UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)` + - `DeleteFirewallRule(tenantID, ruleID string) error` + - `ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error` — пересчитывает `priority = index+1` в транзакции (для drag-and-drop). +- **Policy evaluation** (чистая функция, без I/O): + - `EvaluateFirewallPolicy(client *FirewallClient, rules []*FirewallRule, prefixesByCommunity map[string][]string) []string` — возвращает плоский список CIDR, которые нужно блокировать. Алгоритм описан выше. +- Для репликации: + - `ListApprovedFirewallClientsForReplication(tenantID string) ([]FirewallClientReplicationRow, error)` + - `ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)` -Реализации: +Реализации: `internal/repository/postgres_firewall.go` (клиенты + правила + policy helpers), `internal/store/memory.go` (in-memory). -- `internal/repository/postgres_firewall_client.go` (по образцу `postgres_api_key.go`) -- `internal/store/memory.go` (in-memory для тестов/dev) +Чистую функцию `EvaluateFirewallPolicy` вынести в `internal/firewall/policy.go` (новый пакет, без зависимостей от store/httpapi — легче тестировать). Это соответствует ARCH-08 (разделение слоёв). -## Шаг 4 — HTTP API +## Шаг 4 — CP HTTP API Новый файл `internal/httpapi/routes_firewall.go` + регистрация в `registerV1` (`routes.go:80`): ```go -m.HandleFunc("POST /firewall/enroll", s.handleFirewallEnroll) // X-EvoBGP-Seed авторизация -m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients) // operator+ +// Client lifecycle +m.HandleFunc("POST /firewall/enroll", s.handleFirewallEnroll) +m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients) m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient) 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/blocklist", s.handleFirewallBlocklist) // role=firewall -m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport) // role=firewall + +// Rules: tenant-default (scope="tenant") + client overrides +m.HandleFunc("GET /firewall/rules", s.handleListFirewallRules) // ?scope=tenant|client&client_id=... +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) // body: {scope, client_id, ordered_ids} + +// Data-plane (role=firewall) +m.HandleFunc("GET /firewall/blocklist", s.handleFirewallBlocklist) +m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport) + +// Public scripts (без Bearer — см. раздел «Публичные маршруты») +m.HandleFunc("GET /firewall/install.sh", s.handleFirewallInstallScript) +m.HandleFunc("GET /firewall/sync-script", s.handleFirewallSyncScript) ``` +### 4.0 Публичные маршруты (критично для enroll/install) + +Сейчас **весь** `/v1/*` оборачивается в `authMiddleware`, который **требует** `Authorization: Bearer` (`auth.go:57-64`). `POST /firewall/enroll` и `GET /firewall/install.sh` **не могут** проходить через этот middleware. + +Решение (как у `GET /v1/health` на корневом mux): + +- Зарегистрировать на **корневом** `s.mux` (в `Handler()`, до или вместо единого wrap): + - `POST /v1/firewall/enroll` — handler с проверкой только `X-EvoBGP-Seed` (без Bearer). + - `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script` — отдача bash без auth. +- Либо: в `authMiddleware` early-return для этих path (менее предпочтительно — смешивает политики). + +Остальные `/v1/firewall/*` остаются за `authMiddleware` (Bearer firewall-token или operator). + ### 4.1 `POST /v1/firewall/enroll` -- Авторизация по заголовку `X-EvoBGP-Seed: `. Сервер сверяет `strings.EqualFold(headerSeed, opts.BundleSeedHex)`. Если BundleSeedHex пустой или не совпадает → 403. -- Body: `{name, hostname, community_id, client_token, client_version}`. `client_token` — генерируется клиентом в формате `evobgp_fw_<32 b64url>` (валидация через новую функцию `authkey.ValidateToken`). -- Tenant для нового клиента — берётся из demo-tenant (`s.store.DemoIDs()`) или первого tenant в системе (как делает `dev`-токен в `auth.go:103-109`). Альтернатива: tenant берётся из tied api_key, которым operator заранее создал «invite» — **не реализуем в первой версии**, упростим до demo/первого tenant. -- Создаёт `firewall_client` со статусом `pending`, `token_hash = authkey.HashToken(client_token)`. -- Ответ `201`: `{client_id, status: "pending", message: "ожидает подтверждения оператором"}`. +- Авторизация по заголовку `X-EvoBGP-Seed: `. `strings.EqualFold(headerSeed, opts.BundleSeedHex)`. Если пусто или не совпадает → 403. +- Body: `{name, hostname, client_token, client_version}`. (community_id убран из enroll — политика задаётся только через rules в UI; для обратной совместимости можно принять `community_id` и автоматически создать одно правило `block` для него — опционально.) +- Tenant — demo/первый tenant. +- Создаёт `firewall_client` со статусом `pending`. +- Ответ `201`: `{client_id, status: "pending", message: "..."}`. -### 4.2 `GET /v1/firewall/blocklist` (роль `firewall`) +### 4.2 `GET /v1/firewall/blocklist` (роль `firewall`) — policy-aware -- `requireFirewall(w, a)`. `a.APIKeyID` — это `firewall_client.id`. -- Загрузить клиент → если `status != "approved"` → `403` с `Retry-After: 60` и `{status, message}`. -- Обновить `TouchFirewallClientLastSeen(id, RemoteAddr, User-Agent)`. -- Найти последнюю ревизию: `s.store.ListRevisions(tenantID, "", "", 1)`. -- Если `community_id` клиента задан — вытащить все префиксы ревизии с этим `CommunityID` через `ListRevisionPrefixes` (пагинация до `limit=500`, далее курсор), фильтр на стороне Go (или новый метод `ListRevisionPrefixesByCommunity` — опционально для перфоманса PERF-01). -- Формат ответа (JSON): +- `requireFirewall(w, a)`. `a.APIKeyID` = `firewall_client.id`. +- Загрузить клиент → если `status != "approved"` → `403` + `Retry-After: 60`. +- `TouchFirewallClientLastSeen(id, "cp", RemoteAddr, User-Agent)`. +- Найти последнюю ревизию: `ListRevisions(tenantID, "", "", 1)`. +- Выгрузить все prefixes ревизии → сгруппировать по community_id: `map[communityID][]prefix`. +- Загрузить все rules клиента: `ListAllFirewallRulesForClient(tenantID, clientID)`. +- `EvaluateFirewallPolicy(client, rules, prefixesByCommunity)` → итоговый список CIDR. +- Заголовки: `X-EvoBGP-Source: cp`, `X-EvoBGP-Revision-ID`, `X-EvoBGP-Generated-At`, `X-EvoBGP-Rules-Version: `. +- JSON-ответ: ```json { "client_id": "", - "community_id": "", "revision_id": "", "generated_at": "2026-07-07T16:00:00Z", + "source": "cp", + "rules_applied": 5, + "communities_evaluated": 3, + "communities_blocked": 2, "prefixes": ["1.2.3.0/24", "5.6.7.8/32", "2001:db8::/32"], "total": 1234, "hash": "sha256:..." } ``` -Альтернативный формат `Accept: text/plain` — построчно `# \n1.2.3.0/24\n...` (для удобства bash). +`Accept: text/plain` — построчно для bash без jq. ### 4.3 `POST /v1/firewall/apply-report` (роль `firewall`) -- Body: `{status: "ok|error", error, prefix_count, ip_count, version, kernel_method: "ipset|nftables|iptables"}`. -- Сервер обновляет `last_apply_*` поля клиента. +- Body: `{status, error, prefix_count, ip_count, version, kernel_method, source}`. +- Сервер обновляет `last_apply_*` поля клиента (включая `last_apply_source`). -### 4.4 Operator-эндпоинты (`/firewall/clients/*`) +### 4.4 Operator endpoints -- `requireAtLeast(w, a, "operator")` для всех (по аналогии с `routes_api_keys.go`). -- `POST /approve` — ставит `status='approved'`, `approved_at=now()`, `approved_by_api_key_id=a.APIKeyID`. После — `firewallResolver.Reload`. -- `POST /revoke` — `status='revoked'`, `revoked_at=now()`. Reload. -- PATCH — смена `community_id`, `name`, `hostname`. -- DELETE — каскадное удаление (или soft-delete через revoked). +- `requireAtLeast(w, a, "operator")` для всех mutate, `viewer` для list/get. +- Client ops: как в предыдущей версии плана. После `approve`/`revoke`/`delete` — `replicateFirewallStateToSpeakers(tenantID)`. +- **Rules ops**: + - `POST /rules` body: `{scope: "tenant"|"client", client_id?, action: "block"|"accept", community_id?, comment?}`. + - `PATCH /rules/{id}` body: `{action?, community_id?, comment?}` (priority не меняется через PATCH — только через `:reorder`). + - `DELETE /rules/{id}` — удаляет правило, остальные приоритеты НЕ пересчитываются (gap допускается, главное — относительный порядок). После любой rule mutation — `replicateFirewallStateToSpeakers(tenantID)`. + - `POST /rules:reorder` body: `{scope, client_id?, ordered_ids: [...]}`. Store пересчитывает `priority = index+1` в транзакции. + +### 4.5 Публичные bash-скрипты + +- `GET /v1/firewall/install.sh` — `text/x-shellscript`, содержимое `scripts/firewall/install.sh`. +- `GET /v1/firewall/sync-script` — `text/x-shellscript`, содержимое `scripts/firewall/evobgp-firewall.sh` (используется install.sh на шаге 4). + +Оба без Bearer; регистрация на корневом mux (см. §4.0). ## Шаг 5 — Firewall Token Resolver @@ -220,111 +673,523 @@ m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport) // rol - `type firewallTokenResolver struct { mu sync.RWMutex; byHash map[string]firewallAuthRow }` - `Lookup(raw string) (firewallAuthRow, bool)` — SHA-256(raw) → hex → map. - `Reload(s store.Backend) error` — `store.ListActiveFirewallClientHashes()` (только `status='approved'`). -- После approve/revoke/PATCH в handlers — `s.firewallResolver.Reload(s.store)`. +- После approve/revoke в handlers — `s.firewallResolver.Reload(s.store)`. - В `auth.go` `resolveAuth` — fallback на `firewallResolver.Lookup` если `apiKeyResolver` промахнулся. -## Шаг 6 — OpenAPI (DOC-02: сначала контракт) +## Шаг 6 — Policy evaluation пакет + +Новый файл `internal/firewall/policy.go` (чистая функция, тестируется изолированно): + +```go +package firewall + +type Rule struct { + ClientID *string + Priority int + Action string // "block" | "accept" + CommunityID *string // nil = wildcard +} + +// Evaluate возвращает плоский список CIDR для блокировки в kernel. +// communityPrefixes: map[communityID][]CIDR. Пустой ключ "" = префиксы без community. +func Evaluate(clientID string, rules []Rule, communityPrefixes map[string][]string) []string { + // merge: client scope rules first (priority asc), then tenant-default (priority asc) + // for each communityID in communityPrefixes: + // first matching rule (community_id nil || match) + // if action == "block" → include prefixes + // if action == "accept" → exclude prefixes + // if no rule matched → default accept (exclude — НЕ block all) + // return flattened unique CIDR list +} +``` + +Этот пакет используется: + +- На CP в `handleFirewallBlocklist`. +- На speaker в `handleFirewallBlocklist` (реплицируется тот же алгоритм — speaker импортирует `internal/firewall`). + +## Шаг 7 — CP→Speaker репликация + +Новый файл `internal/httpapi/firewall_replicate.go`: + +- Функция `replicateFirewallStateToSpeakers(tenantID string)`: + 1. Загружает approved firewall-клиентов: `ListApprovedFirewallClientsForReplication(tenantID)`. + 2. Загружает ВСЕ правила tenant: `ListAllFirewallRulesForReplication(tenantID)` (и tenant-default, и client overrides). + 3. Загружает speakers tenant, у которых `SpeakerNeedsRemoteDispatch == true` И `SpeakerMeta.FirewallFailover == true`. + 4. Для каждого speaker — `POST https:///v1/agent/firewall-replicate` с `Authorization: Bearer ` и payload: + +```json +{ + "tenant_id": "", + "revision_id": "", + "generated_at": "2026-07-07T16:00:00Z", + "clients": [ + {"token_hash_hex": "", "client_id": "", "name": "edge-01"} + ], + "rules": [ + {"client_id": null, "priority": 1, "action": "block", "community_id": ""}, + {"client_id": "", "priority": 1, "action": "accept", "community_id": ""} + ], + "communities": [{"id": "", "community": "65000:100", "title": "drop-ads"}], + "prefixes_by_community": { + "": ["1.2.3.0/24", "5.6.7.8/32"] + } +} +``` + + 5. Best-effort: ошибки логировать в `speaker.meta_json.last_firewall_replicate_*`. +- Триггеры: `handleApproveFirewallClient`, `handleRevokeFirewallClient`, `handleDeleteFirewallClient`, любая rule mutation (create/patch/delete/reorder), `Worker.runDeployApply` после успешного apply. + +Расширение `SpeakerMeta` (`internal/store/speaker_meta.go`): + +```go +type SpeakerMeta struct { + // ...существующие поля... + 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"` +} +``` + +UI: чекбокс «Использовать как fallback для firewall-клиентов» в форме редактирования speaker. + +## Шаг 8 — Speaker-agent: endpoints и storage + +Расширение `internal/agentserver/server.go`: + +### 8.1 Новые routes + +```go +s.mux.HandleFunc("GET /v1/firewall/blocklist", s.handleFirewallBlocklist) +s.mux.HandleFunc("POST /v1/firewall/apply-report", s.handleFirewallApplyReportForward) +s.mux.HandleFunc("POST /v1/agent/firewall-replicate", s.handleFirewallReplicate) +``` + +### 8.2 Storage: `firewall-state.json` + +**Зачем файл, если source of truth — БД на CP?** + +Speaker-agent (`evobgp-agent` на удалённой реплике) **не подключается к PostgreSQL** control plane. Это следует из текущей архитектуры ([docs/remote-speakers.md](docs/remote-speakers.md)): CP общается с репликой только по HTTPS (`POST /v1/agent/sync`, health). Реплика тянет signed bundle с CP, но не читает таблицы `firewall_*` напрямую. + +`firewall-state.json` — **не вторая БД**, а **реплицированный снимок (cache)**, который CP **пушит** на speaker: + +```mermaid +flowchart LR + PG[(PostgreSQL CP)] -->|"source of truth"| API[evobgp-api] + API -->|"POST /v1/agent/firewall-replicate"| Agent[agentserver на speaker] + Agent --> JSON["firewall-state.json
(локальный cache)"] + Client[Linux firewall client] -->|"GET /v1/firewall/blocklist
когда CP недоступен"| Agent + Client -->|"когда CP доступен"| API +``` + +| Вопрос | Ответ | +|---|---| +| Кто пишет? | Только CP (`replicateFirewallStateToSpeakers`) | +| Кто читает? | Speaker при `GET /v1/firewall/blocklist` | +| Зачем не запрос в БД с speaker? | У реплики нет доступа к PG; при падении CP запрос в API тоже невозможен | +| Зачем не только in-memory? | Пережить рестарт agent; отдавать blocklist, пока CP недоступен | +| Что при рассинхроне? | CP всегда авторитетен; speaker отдаёт последний push + `X-EvoBGP-Stale` | + +**Альтернатива (не в MVP):** дать speaker read-only доступ к той же PostgreSQL — усложняет сеть/безопасность и расходится с Remnawave-style моделью. Push-replica проще и работает при изоляции CP. + +Путь: `/var/lib/evobgp-agent/firewall-state.json` (env `EVOBGP_FIREWALL_STATE_FILE`): + +```json +{ + "tenant_id": "", + "revision_id": "", + "generated_at": "2026-07-07T16:00:00Z", + "updated_at": "2026-07-07T16:05:00Z", + "clients_by_hash": { + "": {"client_id": "", "name": "edge-01"} + }, + "rules": [ + {"client_id": null, "priority": 1, "action": "block", "community_id": ""}, + {"client_id": "", "priority": 1, "action": "accept", "community_id": ""} + ], + "prefixes_by_community": { + "": ["1.2.3.0/24", "5.6.7.8/32"] + } +} +``` + +Atomic write через `rename(2)`. Mutex на чтение/запись. + +### 8.3 `handleFirewallBlocklist` — policy-aware + +1. `Bearer ` → SHA-256 → hex → lookup в `clients_by_hash`. Если нет — `401`. +2. Фильтруем `rules` по этому клиенту (его `client_id` + все tenant-default rules где `client_id IS NULL`). +3. `firewall.Evaluate(clientID, rules, prefixes_by_community)` — тот же алгоритм, что на CP. +4. Заголовки: `X-EvoBGP-Source: speaker`, `X-EvoBGP-Stale: true` если `updated_at` старше 1 часа, `Age: `. +5. JSON-ответ идентичен CP. + +### 8.4 `handleFirewallApplyReportForward` + +Speaker не хранит статистику apply — форвардит на CP (best-effort) или отвечает `200 {ok: true, forwarded: false}`. + +### 8.5 `handleFirewallReplicate` (от CP) + +1. Авторизация `EVOBGP_AGENT_SECRET`. +2. Body — см. Шаг 7. +3. Atomic write `firewall-state.json`. +4. Ответ `200 {ok: true, clients_count, rules_count, prefix_count}`. + +### 8.6 Конфиг env + +В `ConfigFromEnv` (`agentserver/server.go:188-213`): + +- `EVOBGP_FIREWALL_FAILOVER_ENABLED` (по умолчанию `0`). +- `EVOBGP_FIREWALL_STATE_FILE` (по умолчанию `/var/lib/evobgp-agent/firewall-state.json`). +- `EVOBGP_CONTROL_PLANE_URL` — для forward apply-report. + +Если `EVOBGP_FIREWALL_FAILOVER_ENABLED != "1"` — endpoints `/v1/firewall/*` возвращают `501`. + +## Шаг 9 — OpenAPI (DOC-02) Обновить `docs/openapi.yaml`: -- Схемы: `FirewallClient`, `FirewallClientCreate`, `FirewallEnrollRequest`, `FirewallEnrollResponse`, `FirewallBlocklist`, `FirewallApplyReport`. +- Схемы: `FirewallClient`, `FirewallClientCreate`, `FirewallRule`, `FirewallRuleCreate`, `FirewallRulePatch`, `FirewallReorderRequest`, `FirewallEnrollRequest`, `FirewallEnrollResponse`, `FirewallBlocklist`, `FirewallApplyReport`, `FirewallReplicateRequest`. - Параметр `X-EvoBGP-Seed` (header) для `/firewall/enroll`. -- 7 операций под тегом `Firewall`. lint: `npx @redocly/cli lint docs/openapi.yaml` (TEST-05). +- Заголовки ответа `X-EvoBGP-Source` (`cp`/`speaker`), `X-EvoBGP-Stale`, `X-EvoBGP-Rules-Version`. +- Query `scope=tenant|client` + `client_id` для `/firewall/rules`. +- ~13 операций под тегом `Firewall`. lint: `npx @redocly/cli lint docs/openapi.yaml` (TEST-05). -## Шаг 7 — Web UI (`apps/web/`) +## Шаг 10 — Web UI (`apps/web/`) -- `apps/web/src/queries/firewall.ts` — key factory + queryOptions (`firewallKeys.all/clients/client(id)`), mutations через `apiJSON`/`apiFetch`. -- `apps/web/src/routes/_auth/firewall.tsx` — страница: - - Список клиентов (таблица через ReUI data-grid): name, hostname, community, status (badge: pending/approved/revoked), last_seen_at, last_apply_at, last_apply_status. - - Кнопки: Approve (для pending), Revoke (для approved), Edit (смена community), Delete, View token (только после создания). - - Раздел «Запросы на подтверждение» (pending) сверху с уведомлением. - - Помощь по установке (один клик копирует curl-bash). -- Добавить пункт в `apps/web/src/components/app-sidebar.tsx` (или где nav): «Firewall клиенты». -- Использовать shadcn/ui компоненты (MCP `plugin-shadcn-shadcn`): `DataTable`, `Badge`, `Dialog`, `Select` (для community). Скилл `.agents/skills/shadcn-react/SKILL.md` — обязательно. -- Перед завершением (WEB-19): `pnpm --filter @evobgp/web run typecheck && lint && build` — все exit 0. +### Навигация и доступ -## Шаг 8 — Bash-скрипты клиента +- Sidebar → группа **«Операции»** (рядом с «Операции», «Задачи»): пункт **«Firewall»** (`/firewall`), иконка `Shield` (lucide-react). +- Роли: просмотр — `viewer+`; CRUD rules / approve / revoke — `operator+` (как apply/rollback). +- Файл: [`apps/web/src/components/layout/app-shell.tsx`](apps/web/src/components/layout/app-shell.tsx) — добавить `{ to: '/firewall', label: 'Firewall', icon: Shield }` в группу «Операции». -Каталог `scripts/firewall/`: +### Макет страницы `/firewall` -### 8.1 `install.sh` (curl-bash one-liner) +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PageHeader: «Firewall blocklist» │ +│ Описание: «Linux-серверы, блокировка префиксов по BGP policy» │ +│ [Обновить] │ +├─────────────────────────────────────────────────────────────────┤ +│ Alert (info): кратко — bash-клиент, approve обязателен, │ +│ accept не блокирует по умолчанию │ +├─────────────────────────────────────────────────────────────────┤ +│ Card «Установка на сервер» ← всегда видна сверху (operator) │ +│ Stepper 1→2→3→4 (см. ниже) │ +├─────────────────────────────────────────────────────────────────┤ +│ Tabs: [Клиенты (N)] [Правила по умолчанию] [Запросы (M)] │ +│ ... содержимое активного таба ... │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Card «Установка на сервер» (главный UX установки) + +Компонент: `apps/web/src/components/firewall/firewall-install-card.tsx`. + +Паттерн — как **Copy compose** у спикеров в legacy [`NetworkSpeakersCard.svelte`](web-legacy-svelte/src/lib/components/network/NetworkSpeakersCard.svelte): форма → сгенерированная команда → кнопка «Копировать» + `toast.success`. + +**Вертикальный stepper** (shadcn не обязателен — нумерованный список + `Badge`): + +| Шаг | Заголовок | Содержимое в Card | +|---|---|---| +| **1** | Настройте правила | Краткий текст + ссылка «Перейти к правилам» (`tab=rules`). Badge: «N правил block» | +| **2** | Соберите команду | Форма (react-hook-form + Zod): | +| | | • **Имя сервера** (`client_name`) — `Input`, placeholder `web-01` | +| | | • **URL API** (`cp_url`) — `Input`, default `window.location.origin` | +| | | • **Bundle seed** (`seed`) — `Input type="password"`, placeholder «EVOBGP_BUNDLE_SEED_HEX»; подсказка: «из env CP, не сохраняется в браузере» | +| | | • Опционально: интервал sync (`sync_interval`, default `5min`) | +| **3** | Выполните на сервере | `pre` с one-liner (monospace, `break-all`), кнопки **[Копировать]** и **[Скачать install.sh]** (optional: `GET /v1/firewall/install.sh` как blob) | +| **4** | Подтвердите клиента | Текст: «После enroll клиент появится в „Запросы“»; кнопка **«Открыть запросы»** (`tab=requests`) | + +**Генерируемая команда** (live preview при изменении формы): ```bash -curl -fsSL https://cp.example.com/firewall/install.sh | \ - EVOBGP_CP_URL=https://cp.example.com \ - EVOBGP_SEED= \ - EVOBGP_COMMUNITY_ID= \ - EVOBGP_CLIENT_NAME="edge-router-01" \ +curl -fsSL ${cp_url}/v1/firewall/install.sh | \ + EVOBGP_CP_URL=${cp_url} \ + EVOBGP_SEED=${seed} \ + EVOBGP_CLIENT_NAME="${client_name}" \ bash ``` -Что делает `install.sh`: +Если `seed` пустой — в preview placeholder `<вставьте_seed>` и кнопка «Копировать» disabled с tooltip. -1. Генерирует `CLIENT_TOKEN="evobgp_fw_$(head -c 32 /dev/urandom | basenc --base64url)"`. -2. `POST ${EVOBGP_CP_URL}/v1/firewall/enroll` с заголовком `X-EvoBGP-Seed: ${EVOBGP_SEED}` и body `{name, hostname, community_id, client_token}`. -3. Получает `client_id`, сохраняет в `/etc/evobgp/firewall.conf` (`CLIENT_ID`, `CLIENT_TOKEN`, `EVOBGP_CP_URL`, `COMMUNITY_ID`). -4. Скачивает `evobgp-firewall.sh` в `/usr/local/sbin/evobgp-firewall.sh`, делает `chmod +x`. -5. Автоопределение backend: проверяет наличие `nft` → `ipset` → `iptables`. -6. Ставит systemd timer `evobgp-firewall.timer` (каждые 5 мин) и сервис, активирует. Fallback — crontab-строка `*/5 * * * *`. +**Prerequisites** (сворачиваемый `Collapsible`): -### 8.2 `evobgp-firewall.sh` (main sync) +- Linux root, curl, bash +- nft / ipset / iptables +- Исходящий HTTPS к `${cp_url}` -Алгоритм (atomic swap): +### Таб «Клиенты» + +Компонент: `firewall-clients-table.tsx` (ReUI `DataGridCard` или shadcn `Table`). + +| Колонка | Отображение | +|---|---| +| Имя | `name` + subtitle `hostname` | +| Статус | `StatusBadge`: pending=warning, approved=success, revoked=destructive | +| Последний запрос | `last_seen_at` relative + `last_seen_at_source` badge (cp/speaker) | +| Последний apply | `last_apply_at` + `last_apply_status` + `prefix_count` | +| Версия | `client_version` monospace xs | +| Действия | `⋯` DropdownMenu: **Открыть**, Approve (если pending), Revoke, Удалить | + +Кнопка в toolbar: **«Установить сервер»** — scroll к `FirewallInstallCard` или focus step 2. + +Пустое состояние (`EmptyState`): «Нет клиентов» + CTA «Скопировать команду установки» → scroll к Install Card. + +### Таб «Правила по умолчанию» + +Компонент: `firewall-rules-editor.tsx` (`scope=tenant`). + +- Drag-and-drop строк (`@dnd-kit/sortable`): priority handle слева. +- Колонки: `#`, **Действие** (`Badge`: block=destructive «Блокировать», accept=success «Разрешить»), **Community** (Select из `GET /v1/directories/communities` или существующий directories query), Comment. +- Toolbar: **«Добавить правило»** → Dialog с полями action, community (nullable = wildcard), comment. +- При `block` + community=* — `AlertDialog` подтверждение: «Заблокирует все community из ревизии». +- Tooltip на заголовке «Действие»: «Accept не блокирует. Без block-правил список пуст.» + +### Таб «Запросы» + +- Фильтр `status=pending` из того же `GET /v1/firewall/clients`. +- Карточки или компактная таблица: name, hostname, enroll time, **Client ID** (mono, copy). +- Кнопка **Approve** (primary) + **Отклонить/Delete**. +- После approve — toast «Клиент одобрен» + invalidate clients + badge на табе «Запросы» обнуляется. + +### Диалог клиента (`firewall-client-dialog.tsx`) + +Открывается по клику на строку или «Открыть». `Dialog` `sm:max-w-2xl`, внутри `Tabs`: + +**Вкладка «Статус»** + +``` +Имя: web-01 Hostname: web-01.example.com +Статус: [approved] Client ID: uuid (copy) +Последний запрос: 2 мин назад (source: cp) +Последний apply: 5 мин назад · ok · 1 234 префикса +Ошибка apply: — +``` + +**Вкладка «Политика сервера»** + +- Тот же `firewall-rules-editor` с `scope=client` + `client_id`. +- Alert: «Переопределяет правила по умолчанию. Client scope проверяется первым.» + +**Вкладка «Превью blocklist»** + +- `GET /v1/firewall/clients/{id}/preview` +- Таблица: Community | Решение (block/accept) | Префиксов | Примеры (первые 3 CIDR) +- Footer: **Итого к блокировке: N CIDR** (может быть 0) +- Кнопка «Обновить превью» + +**Вкладка «Установка»** (для approved — read-only справка) + +- Повтор one-liner **без seed** (только для переустановки sync-script): env без `EVOBGP_SEED`, с пометкой «для нового сервера используйте блок установки сверху». + +### Файлы Web UI + +| Файл | Назначение | +|---|---| +| `apps/web/src/routes/_auth/firewall.tsx` | Страница, табы, compose Install Card + таблицы | +| `apps/web/src/queries/firewall.ts` | queryOptions, mutations, invalidation | +| `apps/web/src/components/firewall/firewall-install-card.tsx` | Stepper + форма + copy command | +| `apps/web/src/components/firewall/firewall-clients-table.tsx` | Список клиентов | +| `apps/web/src/components/firewall/firewall-rules-editor.tsx` | DnD rules (tenant + client scope) | +| `apps/web/src/components/firewall/firewall-client-dialog.tsx` | Диалог с 4 вкладками | +| `apps/web/src/components/firewall/firewall-preview-table.tsx` | Превью effective blocklist | +| `apps/web/src/types/api.ts` | `FirewallClient`, `FirewallRule`, … | + +### API для UI (дополнительно к CRUD) + +- `GET /v1/firewall/install-context` (operator) — опционально в v1.1: `{ suggested_cp_url, install_sh_url }` **без seed** (seed только ручной ввод operator'а). +- v1: `cp_url` = `window.location.origin`; seed — password field в форме. + +### shadcn / ReUI (WEB-01) + +MCP `plugin-shadcn-shadcn` перед реализацией. Компоненты: `Card`, `Tabs`, `Dialog`, `Alert`, `AlertDialog`, `Input`, `Button`, `Badge`, `Collapsible`, `DropdownMenu`, `Form` (react-hook-form), `DataGridCard` или `Table`. Скилл [`.agents/skills/shadcn-react/SKILL.md`](.agents/skills/shadcn-react/SKILL.md). + +Перед завершением (WEB-19): `pnpm --filter @evobgp/web run typecheck && lint && build` — все exit 0. + +## Шаг 11 — Bash-скрипты клиента + +Каталог `scripts/firewall/`. Детальный сценарий установки — в разделе **«Установка скрипта на Linux-сервер»** выше. Клиент получает **уже вычисленный** blocklist и не знает про rules. + +### 11.1 `install.sh` + +См. пошаговый flow в разделе «Установка скрипта». Кратко: enroll → conf → sync-script → backend detect → systemd timer → вывод client_id для approve. + +### 11.2 `evobgp-firewall.sh` (main sync с DNS-failover) 1. Читать `/etc/evobgp/firewall.conf`. -2. `GET ${CP_URL}/v1/firewall/blocklist` с `Authorization: Bearer ${CLIENT_TOKEN}`. - - Если 403 + status `pending` — exit 0, залогировать «ожидает подтверждения». - - Если 401 — exit с ошибкой (токен отозван?). -3. Распарсить JSON (через `jq` или минимальный grep, т.к. не везде есть jq; fallback: запросить `Accept: text/plain`). -4. Подготовить новый набор через backend: - - **nftables**: создать `evobgp_blocklist_v4`/`evobgp_blocklist_v6` sets в новой таблице `inet evobgp_blocklist`, atomic `flush` + add, цепочка `input` с drop. - - **ipset**: `ipset create evobgp_blocklist_v4_new hash:net family inet`, заполнить, `ipset swap` + destroy old. Аналогично для v6. iptables-правило `-m set --match-set evobgp_blocklist_v4 src -j DROP` (idempotently через `-C`). - - **iptables**: вызвать `iptables-restore` с transition-чек-примитивами (медленно, но fallback). -5. `POST ${CP_URL}/v1/firewall/apply-report` с метрикой. -6. Лог в `/var/log/evobgp-firewall.log` + `logger -t evobgp-firewall`. +2. DNS failover: `getent hosts ${CP_URL_HOST}` → список A-записей → `curl --resolve` по очереди. +3. `GET ${CP_URL}/v1/firewall/blocklist` с `Authorization: Bearer ${CLIENT_TOKEN}`. + - 403 + pending → exit 0. + - 401 на всех IP → exit с ошибкой. + - 200 → продолжить. Запомнить `X-EvoBGP-Source`. +4. Распарсить JSON (`jq` или fallback `Accept: text/plain`). +5. Atomic swap через backend (nft/ipset/iptables). Если `total == 0` (только accept / нет block rules) — **удалить** существующие block rules/sets (не оставлять stale block). +6. Сравнить `hash` с `/var/lib/evobgp-firewall/last_hash` — если совпадает, skip kernel swap. +7. `POST ${CP_URL}/v1/firewall/apply-report` + optional `POST .../heartbeat` с source. +8. Лог в `/var/log/evobgp-firewall.log`. -Идемпотентность: финальное состояние = ровно набор IP из ответа (atomic swap гарантирует consistency). +При `X-EvoBGP-Stale: true` — warning в лог, но применить (устаревший блок-лист лучше отсутствия). -### 8.3 `uninstall.sh` +### 11.3 `uninstall.sh` -- Снять systemd unit, убрать crontab, flush/destroy ipset sets / nft table / iptables rules. +- Снять systemd unit, убрать crontab, flush/destroy ipset/nft/iptables. -## Шаг 9 — Конфиг и env +## Шаг 12 — Конфиг и env -В `internal/httpapi/server.go` `Options` уже есть `BundleSeedHex` — переиспользуем. Новых env не нужно. Документировать `EVOBGP_BUNDLE_SEED_HEX` как обязательно-стабильный для firewall-flow в `docs/access.md`. +CP: `EVOBGP_BUNDLE_SEED_HEX` уже есть. Новых env не нужно. -## Шаг 10 — Документация (DOC-01, RU) +Speaker-agent: -- `docs/firewall.md` — раздел: назначение, схема авторизации, install one-liner, операторский flow (approve в UI), troubleshooting. -- Обновить `docs/access.md` — новая роль `firewall` + матрица прав в конце. -- Обновить `docs/api.md` — группа `/v1/firewall/*`. -- Обновить `docs/architecture.md` — упомянуть firewall-клиентов как отдельный class edge-clients. +- `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` +- `EVOBGP_FIREWALL_STATE_FILE=/var/lib/evobgp-agent/firewall-state.json` -## Шаг 11 — Тесты (TEST-01/02) +Документировать в `docs/access.md`, `docs/remote-speakers.md`. -- `internal/store/...` — табличные тесты CRUD `firewall_client` для memory + postgres. -- `internal/httpapi/routes_firewall_test.go` — enroll через seed, approve, blocklist (pending → 403, approved → 200), apply-report. -- `scripts/firewall/test_install.sh` — smoke-тест install.sh в Docker (опционально). +## Шаг 13 — Документация (DOC-01, RU) + +- `docs/firewall.md`: + - Назначение, схема авторизации (seed → pending → approved). + - Install one-liner. + - **Раздел «Установка на сервер»**: prerequisites, one-liner, файлы на диске, systemd, approve flow. + - **Раздел «Политика block/accept»**: tenant vs per-server, приоритет, инвариант «accept не создаёт block all», примеры A–D. + - Архитектура failover через DNS. + - Troubleshooting (`X-EvoBGP-Stale`, как добавить speaker как failover source). +- Обновить `docs/access.md` — роль `firewall` + матрица прав. +- Обновить `docs/api.md` — группа `/v1/firewall/*` + подраздел rules. +- Обновить `docs/architecture.md` — firewall-clients + speaker-failover + policy evaluation пакет. +- Обновить `docs/remote-speakers.md` — `EVOBGP_FIREWALL_FAILOVER_ENABLED`, чекбокс «Firewall failover source». + +## Шаг 14 — Тесты (TEST-01/02) + +- `internal/firewall/policy_test.go` — табличные тесты `Evaluate`: + - empty rules → **пустой** blocklist (default accept, не block all). + - only `accept` rules → пустой blocklist (инвариант). + - explicit `block` on community → только её prefixes. + - tenant `block` wildcard + client `accept` exception → block all except accepted. + - client override wins over tenant-default. + - community not in revision → не в blocklist. + - prefixes без community_id (key ""). +- `internal/store/...` — табличные тесты CRUD `firewall_client` + `firewall_rule` для memory + postgres. +- `internal/httpapi/routes_firewall_test.go`: + - enroll через seed. + - approve, blocklist (pending → 403, approved → 200). + - **rules CRUD + reorder + policy-aware blocklist response**. + - apply-report. + - replicate-trigger после rule mutation. +- `internal/agentserver/firewall_test.go` — handleFirewallBlocklist (нет token → 401, есть token → 200 + правильная policy eval), handleFirewallReplicate (запись state-файла). +- `scripts/firewall/test_install.sh` — smoke в Docker (опционально). ## Порядок выполнения (после подтверждения плана) -1. Миграция `000027` (postgres + sqlite, 4 файла). -2. Store: структуры + интерфейс + memory + postgres реализации. -3. `firewall_resolver.go` + интеграция в `resolveAuth`. -4. Handlers `routes_firewall.go` + регистрация. -5. OpenAPI-обновление + redocly lint. -6. Web UI: queries + route + sidebar link + shadcn-компоненты. -7. Bash-скрипты `scripts/firewall/`. -8. Документация `docs/firewall.md` + правки access.md/api.md/architecture.md. -9. Тесты + локальная проверка: `go vet`, `go test -race`, `golangci-lint`, `pnpm typecheck/lint/build`, `redocly lint`. +1. Миграция `000027` (postgres + sqlite, 4 файла) — `firewall_client` + `firewall_rule`. +2. Пакет `internal/firewall/policy.go` + тесты (чистая функция). +3. Store: структуры + интерфейс + memory + postgres реализации (clients + rules). +4. `firewall_resolver.go` + интеграция в `resolveAuth`. +5. CP handlers `routes_firewall.go` (clients + rules + policy-aware blocklist) + регистрация. +6. OpenAPI-обновление + redocly lint. +7. Web UI: queries + route + tabs (clients + tenant rules + client overrides) + sidebar. +8. Speaker-agent: расширение `agentserver` (3 handler + storage + env) с policy eval. +9. CP→Speaker replicate: `firewall_replicate.go` + hook в approve/revoke/rule-mutation/deployApply + `SpeakerMeta` extension. +10. Bash-скрипты `scripts/firewall/` с DNS-failover. +11. Документация. +12. Тесты + локальная проверка: `go vet`, `go test -race`, `golangci-lint`, `pnpm typecheck/lint/build`, `redocly lint`. ## Ключевые файлы, которые будут затронуты -- Создать: `migrations/{postgres,sqlite}/000027_firewall_client.{up,down}.sql` (4 файла), `internal/repository/postgres_firewall_client.go`, `internal/httpapi/{routes_firewall.go,firewall_resolver.go,routes_firewall_test.go}`, `internal/store/firewall_types.go`, `apps/web/src/queries/firewall.ts`, `apps/web/src/routes/_auth/firewall.tsx`, `scripts/firewall/{install.sh,evobgp-firewall.sh,uninstall.sh}`, `docs/firewall.md`. -- Изменить: `internal/store/backend.go` (новые методы интерфейса), `internal/store/memory.go` (in-memory реализации), `internal/httpapi/{server.go,routes.go,auth.go}` (wiring + resolver), `docs/openapi.yaml`, `docs/{access,api,architecture}.md`, `apps/web/src/components/app-sidebar.tsx`. +Создать: + +- `migrations/{postgres,sqlite}/000027_firewall.{up,down}.sql` (4 файла) +- `internal/firewall/{policy.go,policy_test.go}` +- `internal/repository/postgres_firewall.go` +- `internal/httpapi/{routes_firewall.go,firewall_resolver.go,firewall_replicate.go,routes_firewall_test.go}` +- `internal/store/firewall_types.go` +- `internal/agentserver/{firewall.go,firewall_state.go,firewall_test.go}` +- `apps/web/src/queries/firewall.ts`, `apps/web/src/routes/_auth/firewall.tsx`, `apps/web/src/components/firewall/{rules-editor.tsx,client-overrides.tsx}` +- `scripts/firewall/{install.sh,evobgp-firewall.sh,uninstall.sh}` +- `docs/firewall.md` + +Изменить: + +- `internal/store/backend.go` (новые методы интерфейса + типы) +- `internal/store/memory.go` (in-memory реализации) +- `internal/store/speaker_meta.go` (новые поля `FirewallFailover`, `LastFirewallReplicate*`) +- `internal/httpapi/{server.go,routes.go,auth.go}` (wiring + resolver + bundle seed для enroll) +- `internal/agentserver/server.go` (3 новых handler + Config поля + ConfigFromEnv) +- `internal/jobs/worker.go` (`runDeployApply` — хук `replicateFirewallStateToSpeakers`) +- `docs/openapi.yaml`, `docs/{access,api,architecture,remote-speakers}.md` +- `apps/web/src/components/app-sidebar.tsx` ## Риски и компромиссы -- **Tenant для нового firewall-клиента при enroll**: в v1 берём demo/первый tenant системы. Если нужна мультитенантная привязка — нужна отдельная сущность «firewall invite» (operator создаёт, клиент redeem по invite-коду). Это можно вынести во вторую итерацию. -- **Масштаб ipset**: hash:net при ~10⁵ CIDR работает; для ~10⁶ нужно переключаться на `bitmap`/`hash:net,net` или nftables с interval-sets. В скрипте оставить комментарий с порогом. -- **Безопасность seed**: `X-EvoBGP-Seed` летит по HTTPS. Если CP без TLS — небезопасно. Зафиксировать в docs как requirement. \ No newline at end of file +- **Default = accept (не block):** без совпавших правил community не блокируется. Только явный `block` добавляет IP. **Accept alone never causes block all** — зафиксировано в `policy_test.go`. +- **Wildcard block:** `block` + `community_id=null` блокирует все community из ревизии — только при явном действии operator; UI confirmation dialog. +- **UNIQUE NULL semantics**: `UNIQUE (tenant_id, client_id, priority)` в Postgres считает NULL значения не конфликтующими. Для гарантии — валидация в store, что для tenant-default (`client_id IS NULL`) нет двух правил с одинаковым priority. +- **Gap в priorities после DELETE**: допускаются gaps (1, 2, 5, 10) — относительный порядок сохраняется. Reorder — опциональная операция через `:reorder` endpoint. +- **Policy eval на больших prefixes**: при ~10⁵ CIDR в community вычисление на каждый запрос может быть медленным. Кеш по `rules_version` (hash от rules) — если rules не изменились, переиспользовать вычисленный список. Реализация: in-memory кеш в `Server` с TTL 30s или инвалидацией при rule mutation. +- **Tenant для нового firewall-клиента**: в v1 берём demo/первый tenant системы. Мультитенантная привязка через «firewall invite» — во второй итерации. +- **Масштаб ipset**: hash:net при ~10⁵ CIDR работает; для ~10⁶ — `bitmap`/`hash:net,net` или nftables interval-sets. В скрипте комментарий с порогом. +- **Размер replication-payload**: rules + prefixes могут быть 1+ MiB. Gzip в `Content-Encoding: gzip`. На speaker — кеш в памяти после распаковки. +- **Stale-данные на speaker**: при долгой недоступности CP speaker отдаёт кеш с `X-EvoBGP-Stale: true`. Operator видит `last_apply_source` в UI. +- **Безопасность seed**: `X-EvoBGP-Seed` летит по HTTPS. Без TLS — небезопасно. Зафиксировать в docs. +- **DNS-failover без split-brain**: если DNS возвращает и CP, и speaker, и клиент попал на speaker без реплицированных данных — `503` (state пустой). Retry на следующий IP. На стороне клиента — exit non-zero, залогировать. +- **Speaker как failover source требует домен с DNS**: альтернатива — `EVOBGP_FAILOVER_URLS` env на клиенте (явный список URL). + +## Аудит покрытия требований + +### Исходный запрос пользователя — статус + +| Требование | В плане | Замечание | +|---|---|---| +| HTTP по токену + community → список IP | Да | Community задаётся **правилами** (tenant + per-client), не query-параметром на каждый запрос — осознанное расширение | +| Блокировка на Linux (iptables/ufw) | Частично | ipset/nftables/iptables; **ufw** не отдельный backend — см. дополнение ниже | +| Контроль последнего запроса/обновления | Да | `last_seen_at`, `last_apply_at`, `last_apply_source`, apply-report | +| Автоустановка на клиент | Да | curl-bash install + systemd timer / cron | +| Токен генерирует клиент | Да | `evobgp_fw_*` в install.sh | +| Подтверждение в UI EvoBGP | Да | pending → approve operator | +| Первая авторизация через `EVOBGP_BUNDLE_SEED_HEX` | Да | `X-EvoBGP-Seed` на enroll | +| Только bash на клиенте | Да | `scripts/firewall/*.sh` | +| Fallback со спикера при недоступности CP | Да | DNS + speaker-agent + replicate | +| Allow/deny community из UI | Да | Переименовано в **`block`/`accept`**; default accept; accept не создаёт block all | +| Установка на сервер (детально) | Да | Принципы + пошаговый flow + файлы на диске | +| Web UI установки | Да | FirewallInstallCard, stepper 1–4, copy compose-паттерн | +| Per-server block/accept | Да | Tenant-default + client overrides + preview | + +### Инженерные правила EvoBGP — статус + +| Правило | В плане | Доработка | +|---|---|---| +| ARCH-01 store.Backend + repository | Да | SQL только в `repository/postgres_firewall.go` | +| ARCH-02 маршруты в httpapi | Да | | +| DEP-03 миграции postgres+sqlite | Да | Runtime store: postgres + memory (как весь проект); sqlite-миграции для dev/CI parity | +| DOC-02 OpenAPI первым | Да | шаг 9 в порядке выполнения — после handlers; **рекомендуется** OpenAPI сразу после контракта policy API | +| WEB-19 typecheck/lint/build | Да | | +| ERR-01 problem+json | Да | явно в handlers (writeProblem) | +| PERF-01 пагинация | Частично | blocklist — один ответ (может быть большим); rules list — мало строк, пагинация не обязательна | +| PERF-03 метрики | **Нет** | добавить: `evobgp_firewall_blocklist_requests_total`, `evobgp_firewall_clients_pending` | +| SEC-02 seed не в prod git | Да | docs | +| Memory backend | **Нет** | при `store=memory` firewall CRUD работает; replicate на speaker — no-op; документировать | + +### Выявленные пробелы (добавить при реализации) + +1. **Публичный enroll/install** — без исключения из `authMiddleware` enroll сломан (§4.0). +2. **`GET /firewall/sync-script`** — был в bash, отсутствовал в routes (исправлено выше). +3. **`last_seen_at` при обращении к speaker** — CP не узнает о poll, если клиент ходит только на speaker. Варианты: (a) speaker форвардит lightweight heartbeat на CP (как apply-report); (b) принять, что `last_seen_at` обновляется только при успешном CP; (c) speaker пишет в replicate meta. **Рекомендация:** speaker при `GET /blocklist` best-effort `POST` на CP `/v1/firewall/clients/{id}/heartbeat` (новый лёгкий endpoint) или расширить apply-report forward. Добавить в план: `POST /v1/firewall/heartbeat` (role=firewall, только `last_seen_at` + source). +4. **Нет ревизии** — `GET /blocklist` при пустом `ListRevisions` → `404` или `200` с `prefixes: []` и `revision_id: null`. Зафиксировать: `404` + problem «no published revision». +5. **Префиксы без community** (`community_id IS NULL` в `PrefixRow`) — ключ `""` или `"none"` в `prefixesByCommunity`; правила wildcard должны их покрывать. Явный тест в `policy_test.go`. +6. **UFW** — UFW управляет iptables; при `ufw` active скрипт должен: (a) предупреждать в лог, (b) добавлять правила через `iptables`/`ip6tables` в цепочки, которые ufw не перезаписывает, **или** (c) документировать «отключить ufw / использовать raw iptables». Минимум v1: detect `ufw status` → warning в install.sh + docs. +7. **Направление блокировки** — зафиксировать в docs/bash: **INPUT**, match **src** (входящий трафик от listed CIDR). Опционально env `EVOBGP_FIREWALL_DIRECTION=in|out|both`. +8. **Hash skip** — если ответ `hash` совпадает с `/var/lib/evobgp-firewall/last_hash` — пропустить kernel swap (экономия CPU). Добавить в bash §11.2. +9. **Tenant при enroll** — demo/первый tenant — риск в multi-tenant prod. v2: firewall-invite с привязкой tenant. Явно в docs как ограничение v1. +10. **UI preview** — operator видит effective blocklist для клиента (dry-run policy) до approve: `GET /v1/firewall/clients/{id}/preview` (operator, без применения на клиенте). Улучшает UX, не блокирует MVP. +11. **Replicate gzip** — в `firewall_replicate.go` включить `Content-Encoding: gzip` при payload > 64 KiB; speaker распаковывает. +12. **SQLite store** — отдельный sqlite repository не нужен (в проекте только postgres+memory); миграции sqlite — для `db.OpenSQLite` dev-пути. +13. **Семантика action в UI** — `block` = «Блокировать IP», `accept` = «Разрешить (не блокировать)». Tooltips + docs. Accept-only policy → empty blocklist. +14. **Rate limit enroll** — защита от flood при утечке seed: max N enroll/час per IP или глобально (опционально v1, иначе v1.1). + +### Противоречия в плане (исправлены в этом аудите) + +- Todo упоминал несуществующую таблицу `firewall_client_rule` → одна таблица `firewall_rule`. +- Sequence diagram: путь `clients/{id}/rules` → `POST /firewall/rules?scope=client`. +- Replication JSON: `name` → `community` + `title` (схема `bgp_community`). +- Отсутствовал endpoint `sync-script` при наличии в install flow. diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 2f321d4..aa9d5bd 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 }, ], diff --git a/apps/web/src/queries/firewall.ts b/apps/web/src/queries/firewall.ts new file mode 100644 index 0000000..fce8dd9 --- /dev/null +++ b/apps/web/src/queries/firewall.ts @@ -0,0 +1,67 @@ +import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiJSON } from '@/lib/api-client' +import type { FirewallClient, FirewallClientsResponse, FirewallRule, FirewallRulesResponse } from '@/types/api' + +export const firewallKeys = { + all: ['firewall'] as const, + clients: () => [...firewallKeys.all, 'clients'] as const, + rules: (scope: string, clientId?: string) => + [...firewallKeys.all, 'rules', scope, clientId ?? ''] as const, +} + +export function firewallClientsQueryOptions() { + return queryOptions({ + queryKey: firewallKeys.clients(), + queryFn: () => apiJSON('/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({ + queryKey: firewallKeys.rules(scope, clientId), + queryFn: () => apiJSON(`/v1/firewall/rules${qs}`), + staleTime: 15_000, + }) +} + +export function useApproveFirewallClient() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiJSON(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: firewallKeys.clients() }) + }, + }) +} + +export function useCreateFirewallRule() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: Record) => + apiJSON('/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(`/v1/firewall/rules/${id}`, { method: 'DELETE' }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: firewallKeys.all }) + }, + }) +} diff --git a/apps/web/src/routes/_auth/firewall.tsx b/apps/web/src/routes/_auth/firewall.tsx new file mode 100644 index 0000000..51cc91a --- /dev/null +++ b/apps/web/src/routes/_auth/firewall.tsx @@ -0,0 +1,293 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { Copy, Info, RefreshCw, Shield } from 'lucide-react' +import { 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 { PageHeader } from '@/components/page-header' +import { StatusBadge } from '@/components/status-badge' +import { + firewallClientsQueryOptions, + firewallRulesQueryOptions, + useApproveFirewallClient, + useCreateFirewallRule, + useDeleteFirewallRule, +} from '@/queries/firewall' +import type { FirewallClient } from '@/types/api' + +export const Route = createFileRoute('/_auth/firewall')({ + component: FirewallPage, +}) + +function FirewallPage() { + const clientsQ = useQuery(firewallClientsQueryOptions()) + const rulesQ = useQuery(firewallRulesQueryOptions('tenant')) + const approve = useApproveFirewallClient() + const createRule = useCreateFirewallRule() + const deleteRule = useDeleteFirewallRule() + + const [clientName, setClientName] = useState('web-01') + const [cpUrl, setCpUrl] = useState(() => + typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com', + ) + const [seed, setSeed] = useState('') + const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block') + const [ruleComment, setRuleComment] = useState('') + + 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() || '' + 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('Укажите bundle seed') + return + } + try { + await navigator.clipboard.writeText(installCmd) + toast.success('Команда скопирована') + } catch { + toast.error('Не удалось скопировать') + } + } + + return ( +
+ { + void clientsQ.refetch() + void rulesQ.refetch() + }} + disabled={clientsQ.isFetching} + > + + Обновить + + } + /> + + + + Политика + + Только явный block добавляет IP в blocklist. Правила accept сами по себе + не создают block all. Default — accept. + + + + + + + + Установка на сервер + + One-liner для root на целевом Linux (bash, curl). После enroll — approve в «Запросы». + + +
+
+ + setClientName(e.target.value)} /> +
+
+ + setCpUrl(e.target.value)} /> +
+
+ + setSeed(e.target.value)} + /> +
+
+
{installCmd}
+ +
+
+ + + + Клиенты ({clients.length}) + Правила ({rules.length}) + Запросы ({pending.length}) + + + + approve.mutate(id)} /> + + + +
+
+ + +
+ setRuleComment(e.target.value)} + /> + +
+ deleteRule.mutate(id)} /> +
+ + + approve.mutate(id)} + emptyTitle="Нет pending-запросов" + /> + +
+
+ ) +} + +function ClientsTable({ + clients, + onApprove, + emptyTitle = 'Нет клиентов', +}: { + clients: FirewallClient[] + onApprove: (id: string) => void + emptyTitle?: string +}) { + if (clients.length === 0) { + return

{emptyTitle}

+ } + return ( + + + + Имя + Статус + Last seen + Apply + + + + + {clients.map((c) => ( + + +
{c.name}
+
{c.hostname || c.token_prefix}
+
+ + + + {c.last_seen_at?.slice(0, 19) ?? '—'} + + {c.last_apply_status ?? '—'} + {c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''} + + + {c.status === 'pending' ? ( + + ) : null} + +
+ ))} +
+
+ ) +} + +function RulesTable({ + rules, + onDelete, +}: { + rules: { id: string; priority: number; action: string; comment?: string }[] + onDelete: (id: string) => void +}) { + if (rules.length === 0) { + return

Нет правил — blocklist пуст (default accept).

+ } + return ( + + + + # + Действие + Комментарий + + + + + {rules.map((r) => ( + + {r.priority} + + + + {r.comment || '—'} + + + + + ))} + +
+ ) +} diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 28a1d9a..f73f47c 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -343,3 +343,33 @@ 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[] } diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 1aea66a..6c2185e 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -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/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/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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/docs/access.md b/docs/access.md index f6c8b04..029ffb1 100644 --- a/docs/access.md +++ b/docs/access.md @@ -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`**; остальные роли получают отказ. diff --git a/docs/architecture.md b/docs/architecture.md index 04d993b..bde0cbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. | ## Удалённые спикеры diff --git a/docs/firewall.md b/docs/firewall.md new file mode 100644 index 0000000..1e39d11 --- /dev/null +++ b/docs/firewall.md @@ -0,0 +1,35 @@ +# 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 `. + +## Политика block/accept + +- **`block`** — добавить префиксы community в kernel blocklist. +- **`accept`** — не блокировать. +- **Default** — accept (пустой blocklist без явных `block`). + +Правила задаются на уровне tenant (по умолчанию) и per-server (overrides клиента). Client scope проверяется раньше tenant-default. + +## Установка на сервер + +```bash +curl -fsSL https:///v1/firewall/install.sh | \ + EVOBGP_CP_URL=https:// \ + EVOBGP_SEED= \ + EVOBGP_CLIENT_NAME="web-01" \ + bash +``` + +Файлы: `/etc/evobgp/firewall.conf`, `/usr/local/sbin/evobgp-firewall.sh`, systemd timer `evobgp-firewall.timer`. + +## 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). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 6e1a3a3..3c27d81 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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,68 @@ 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 + + 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 +4363,134 @@ paths: $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/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" diff --git a/docs/remote-speakers.md b/docs/remote-speakers.md index 5408aee..fdb5ce3 100644 --- a/docs/remote-speakers.md +++ b/docs/remote-speakers.md @@ -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 на реплике | diff --git a/internal/agentserver/firewall.go b/internal/agentserver/firewall.go new file mode 100644 index 0000000..c60f27a --- /dev/null +++ b/internal/agentserver/firewall.go @@ -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:]) +} diff --git a/internal/agentserver/server.go b/internal/agentserver/server.go index 9918981..e039ff4 100644 --- a/internal/agentserver/server.go +++ b/internal/agentserver/server.go @@ -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 } diff --git a/internal/firewall/policy.go b/internal/firewall/policy.go new file mode 100644 index 0000000..e2961cc --- /dev/null +++ b/internal/firewall/policy.go @@ -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[:]) +} diff --git a/internal/firewall/policy_test.go b/internal/firewall/policy_test.go new file mode 100644 index 0000000..9bf2938 --- /dev/null +++ b/internal/firewall/policy_test.go @@ -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) + } +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index b331bfb..2677a54 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -4,6 +4,8 @@ import ( "context" "net/http" "strings" + + "evobgp/internal/authkey" ) type ctxKey int @@ -95,6 +97,14 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) { } 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 @@ -134,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") diff --git a/internal/httpapi/firewall_replicate.go b/internal/httpapi/firewall_replicate.go new file mode 100644 index 0000000..68dade1 --- /dev/null +++ b/internal/httpapi/firewall_replicate.go @@ -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 +} diff --git a/internal/httpapi/firewall_resolver.go b/internal/httpapi/firewall_resolver.go new file mode 100644 index 0000000..d843b08 --- /dev/null +++ b/internal/httpapi/firewall_resolver.go @@ -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 +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index f751ccc..3021e7a 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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) { diff --git a/internal/httpapi/routes_firewall.go b/internal/httpapi/routes_firewall.go new file mode 100644 index 0000000..85e8e1e --- /dev/null +++ b/internal/httpapi/routes_firewall.go @@ -0,0 +1,609 @@ +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/store" +) + +func (s *Server) registerFirewallRoutes(m *http.ServeMux) { + 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) 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 + } + writeInternalError(w, "internal", 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) { + path := filepath.Join("scripts", "firewall", name) + b, err := os.ReadFile(path) + 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 (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 +} diff --git a/internal/httpapi/routes_firewall_test.go b/internal/httpapi/routes_firewall_test.go new file mode 100644 index 0000000..985eee9 --- /dev/null +++ b/internal/httpapi/routes_firewall_test.go @@ -0,0 +1,128 @@ +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 TestFirewallTokenHashMatchesAuthkey(t *testing.T) { + tok := "evobgp_fw_sample" + h := authkey.HashToken(tok) + if len(h) != 32 { + t.Fatalf("hash len %d", len(h)) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 01c093a..3affbeb 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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()), diff --git a/internal/repository/postgres_firewall.go b/internal/repository/postgres_firewall.go new file mode 100644 index 0000000..4a3973a --- /dev/null +++ b/internal/repository/postgres_firewall.go @@ -0,0 +1,494 @@ +package repository + +import ( + "context" + "encoding/hex" + "errors" + "strings" + "time" + + "evobgp/internal/store" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) { + ctx := context.Background() + rows, err := p.pool.Query(ctx, ` + SELECT id, name, hostname, token_prefix, status, + last_seen_at, last_seen_at_source, last_seen_ip, + last_apply_at, last_apply_status, last_apply_error, + last_apply_prefix_count, last_apply_ip_count, last_apply_source, + client_version, created_at, approved_at, approved_by_api_key_id, revoked_at + 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 id, name, hostname, token_prefix, status, + last_seen_at, last_seen_at_source, last_seen_ip, + last_apply_at, last_apply_status, last_apply_error, + last_apply_prefix_count, last_apply_ip_count, last_apply_source, + client_version, created_at, approved_at, approved_by_api_key_id, revoked_at + 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, id, name, hostname, token_prefix, status, + last_seen_at, last_seen_at_source, last_seen_ip, + last_apply_at, last_apply_status, last_apply_error, + last_apply_prefix_count, last_apply_ip_count, last_apply_source, + client_version, created_at, approved_at, approved_by_api_key_id, revoked_at + 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 +} diff --git a/internal/store/backend.go b/internal/store/backend.go index 5cc3a0b..afff8b2 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -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. diff --git a/internal/store/firewall_types.go b/internal/store/firewall_types.go new file mode 100644 index 0000000..f5370b5 --- /dev/null +++ b/internal/store/firewall_types.go @@ -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 + } +} diff --git a/internal/store/memory.go b/internal/store/memory.go index 3798eb3..f2dd2a0 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -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, diff --git a/internal/store/memory_firewall.go b/internal/store/memory_firewall.go new file mode 100644 index 0000000..81c0289 --- /dev/null +++ b/internal/store/memory_firewall.go @@ -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 +} diff --git a/internal/store/speaker_meta.go b/internal/store/speaker_meta.go index a37108b..3fd9355 100644 --- a/internal/store/speaker_meta.go +++ b/internal/store/speaker_meta.go @@ -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) } diff --git a/migrations/postgres/000027_firewall.down.sql b/migrations/postgres/000027_firewall.down.sql new file mode 100644 index 0000000..a617057 --- /dev/null +++ b/migrations/postgres/000027_firewall.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS firewall_rule; +DROP TABLE IF EXISTS firewall_client; diff --git a/migrations/postgres/000027_firewall.up.sql b/migrations/postgres/000027_firewall.up.sql new file mode 100644 index 0000000..f395cbd --- /dev/null +++ b/migrations/postgres/000027_firewall.up.sql @@ -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; diff --git a/migrations/sqlite/000027_firewall.down.sql b/migrations/sqlite/000027_firewall.down.sql new file mode 100644 index 0000000..a617057 --- /dev/null +++ b/migrations/sqlite/000027_firewall.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS firewall_rule; +DROP TABLE IF EXISTS firewall_client; diff --git a/migrations/sqlite/000027_firewall.up.sql b/migrations/sqlite/000027_firewall.up.sql new file mode 100644 index 0000000..8a5984f --- /dev/null +++ b/migrations/sqlite/000027_firewall.up.sql @@ -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; diff --git a/scripts/firewall/evobgp-firewall.sh b/scripts/firewall/evobgp-firewall.sh new file mode 100644 index 0000000..7243849 --- /dev/null +++ b/scripts/firewall/evobgp-firewall.sh @@ -0,0 +1,169 @@ +#!/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" + +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:?}" + +mkdir -p "$STATE_DIR" +BACKEND="${KERNEL_BACKEND:-auto}" + +curl_get_blocklist() { + local url="$1" + local host + host=$(echo "$url" | sed -E 's#https?://([^/]+)/?.*#\1#') + local tmp + tmp=$(mktemp) + local code + code=$(curl -sS -o "$tmp" -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" + rm -f "$tmp" + exit 0 + fi + if [[ "$code" != "200" ]]; then + log "blocklist HTTP $code from $url" + rm -f "$tmp" + return 1 + fi + cat "$tmp" + rm -f "$tmp" +} + +try_urls() { + 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%/}" + if OUT=$(curl_get_blocklist "$u"); then + CP_HIT="$u" + return 0 + fi + done + return 1 +} + +if ! OUT=$(try_urls); then + log "all endpoints failed" + exit 1 +fi + +if command -v jq >/dev/null 2>&1; then + HASH=$(echo "$OUT" | jq -r '.hash // empty') + TOTAL=$(echo "$OUT" | jq -r '.total // 0') + mapfile -t PREFIXES < <(echo "$OUT" | jq -r '.prefixes[]?') +else + HASH=$(echo "$OUT" | grep -o '"hash"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\(sha256:[^"]*\)".*/\1/') + TOTAL=$(echo "$OUT" | grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | grep -o '[0-9]*$') + mapfile -t PREFIXES < <(echo "$OUT" | grep -o '"[0-9a-fA-F:.]*/[0-9]*"' | tr -d '"') +fi + +if [[ -f "$HASH_FILE" && "$(cat "$HASH_FILE")" == "$HASH" ]]; then + log "unchanged hash $HASH — skip kernel apply" + exit 0 +fi + +apply_nft() { + local table=inet + local name=evobgp_blocklist + 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 ((${#PREFIXES[@]})); then + local v4=() + local p + for p in "${PREFIXES[@]}"; do + [[ "$p" == *:* ]] && continue + v4+=("$p") + done + if ((${#v4[@]})); then + nft add element "$table" "$name" v4 "{ $(IFS=,; echo "${v4[*]}") }" + fi + fi + nft list chain "$table" "$name" input >/dev/null 2>&1 || { + nft add chain "$table" "$name" input '{ type filter hook input priority 0; }' + nft add rule "$table" "$name" input ip saddr @v4 drop + } +} + +apply_ipset() { + local set=evobgp_blocklist_v4 + 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 + 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 +} + +apply_iptables_only() { + iptables -D INPUT -m comment --comment evobgp-block -j DROP 2>/dev/null || true + if ((${#PREFIXES[@]})); then + 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 + done + fi +} + +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 +} + +if [[ "$TOTAL" == "0" || ${#PREFIXES[@]} -eq 0 ]]; then + clear_block +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 +fi + +echo "$HASH" >"$HASH_FILE" +log "applied $TOTAL prefixes from ${CP_HIT:-$EVOBGP_CP_URL} backend=$BACKEND" + +REPORT=$(printf '{"status":"ok","prefix_count":%s,"ip_count":0,"source":"cp"}' "${TOTAL:-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 diff --git a/scripts/firewall/install.sh b/scripts/firewall/install.sh new file mode 100644 index 0000000..367446f --- /dev/null +++ b/scripts/firewall/install.sh @@ -0,0 +1,110 @@ +#!/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 + +: "${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") + +RESP=$(curl -fsS -X POST "${CP_URL}/v1/firewall/enroll" \ + -H "Content-Type: application/json" \ + -H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \ + -d "$ENROLL_BODY") + +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" </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 </etc/cron.d/evobgp-firewall +fi + +echo "Client ID: ${CLIENT_ID}" +echo "Status: pending — approve in EvoBGP UI → Firewall → Запросы" diff --git a/scripts/firewall/uninstall.sh b/scripts/firewall/uninstall.sh new file mode 100644 index 0000000..7e3970e --- /dev/null +++ b/scripts/firewall/uninstall.sh @@ -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"