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"