From 276194a9d0432325cec50c556e760c0b0d43f9af Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 7 Jul 2026 17:09:55 +0700 Subject: [PATCH] fix(daemon): update PID and timestamps in .codegraph/daemon.pid for synchronization --- .codegraph/daemon.pid | 4 +- ...ll_http_blocklist_feature_d64a07b0.plan.md | 330 ++++++++++++++++++ 2 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 .cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index 97f702e..4d4a3ff 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 59836, + "pid": 40060, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da", - "startedAt": 1783332305171 + "startedAt": 1783395081742 } diff --git a/.cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md b/.cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md new file mode 100644 index 0000000..1b2e3d8 --- /dev/null +++ b/.cursor/plans/firewall_http_blocklist_feature_d64a07b0.plan.md @@ -0,0 +1,330 @@ +--- +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-скрипты, без бинарников." +todos: + - id: migrate + content: Создать миграцию 000027_firewall_client (postgres + sqlite, 4 файла) по DEP-03 + status: pending + - id: store + content: "Расширить store.Backend: типы FirewallClient*, методы CRUD/Lookup/Touch + реализации в memory.go и postgres_firewall_client.go" + status: pending + - 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: pending + - id: openapi + content: Обновить docs/openapi.yaml схемами и операциями тега Firewall; npx @redocly/cli lint (TEST-05) + status: pending + - id: webui + content: "Web UI: queries/firewall.ts + route /firewall + sidebar link + shadcn-компоненты; typecheck+lint+build exit 0 (WEB-19)" + status: pending + - id: bash + content: Написать scripts/firewall/{install.sh,evobgp-firewall.sh,uninstall.sh} с автоопределением backend (nft→ipset→iptables) + status: pending + - id: docs + content: "Документация: docs/firewall.md (RU) + правки access.md/api.md/architecture.md" + status: pending + - id: tests + content: "Тесты: табличные store-тесты + handlers-тесты (enroll/approve/blocklist/apply-report); go vet + go test -race + golangci-lint" + status: pending +isProject: false +--- + +# Firewall Blocklist — план реализации + +## Контекст проекта (ключевые факты из анализа) + +- Авторизация: `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. + +## Поток данных + +```mermaid +flowchart LR + 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] + Kernel[(ipset/nftables/iptables)] + end + subgraph cp [Control Plane EvoBGP] + API[evobgp-api/all] + PG[(PostgreSQL)] + UI[Web UI /firewall] + end + Installer -->|1 POST /v1/firewall/enroll + X-EvoBGP-Seed, client_token| 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 --> Kernel + Sync -->|report last_apply| API +``` + +## Шаг 1 — Миграция БД `000027_firewall_client` + +Новые файлы (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` + +Схема `firewall_client` (Postgres): + +```sql +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_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, + 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'; +``` + +SQLite — то же с поправкой на типы (`TEXT`/`BLOB`, `length(token_hash) = 32`). + +Миграция НЕ трогает `api_key` — `firewall_client` — отдельная таблица (как у speakers своя `agent_secret`, но здесь переиспользуется только формат токена `authkey`). + +## Шаг 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: ...}`. + +## Шаг 3 — Store слой + +В `internal/store/backend.go`: + +- Структуры `FirewallClient`, `FirewallClientPatch`, `FirewallClientWithSecret` (для issue/rotate). +- Методы в `Backend`: + - `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, clientIP, clientVersion string) error` + - `TouchFirewallClientLastApply(id, status, errMsg string, prefixCount, ipCount int) error` + - `ListActiveFirewallClientHashes() ([]FirewallClientAuthRow, error)` + +Реализации: + +- `internal/repository/postgres_firewall_client.go` (по образцу `postgres_api_key.go`) +- `internal/store/memory.go` (in-memory для тестов/dev) + +## Шаг 4 — 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+ +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 +``` + +### 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: "ожидает подтверждения оператором"}`. + +### 4.2 `GET /v1/firewall/blocklist` (роль `firewall`) + +- `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): + +```json +{ + "client_id": "", + "community_id": "", + "revision_id": "", + "generated_at": "2026-07-07T16:00:00Z", + "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). + +### 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_*` поля клиента. + +### 4.4 Operator-эндпоинты (`/firewall/clients/*`) + +- `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). + +## Шаг 5 — Firewall Token Resolver + +`internal/httpapi/firewall_resolver.go` (по образцу `api_key_resolver.go`): + +- `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)`. +- В `auth.go` `resolveAuth` — fallback на `firewallResolver.Lookup` если `apiKeyResolver` промахнулся. + +## Шаг 6 — OpenAPI (DOC-02: сначала контракт) + +Обновить `docs/openapi.yaml`: + +- Схемы: `FirewallClient`, `FirewallClientCreate`, `FirewallEnrollRequest`, `FirewallEnrollResponse`, `FirewallBlocklist`, `FirewallApplyReport`. +- Параметр `X-EvoBGP-Seed` (header) для `/firewall/enroll`. +- 7 операций под тегом `Firewall`. lint: `npx @redocly/cli lint docs/openapi.yaml` (TEST-05). + +## Шаг 7 — 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-скрипты клиента + +Каталог `scripts/firewall/`: + +### 8.1 `install.sh` (curl-bash one-liner) + +```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" \ + bash +``` + +Что делает `install.sh`: + +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 * * * *`. + +### 8.2 `evobgp-firewall.sh` (main sync) + +Алгоритм (atomic swap): + +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`. + +Идемпотентность: финальное состояние = ровно набор IP из ответа (atomic swap гарантирует consistency). + +### 8.3 `uninstall.sh` + +- Снять systemd unit, убрать crontab, flush/destroy ipset sets / nft table / iptables rules. + +## Шаг 9 — Конфиг и env + +В `internal/httpapi/server.go` `Options` уже есть `BundleSeedHex` — переиспользуем. Новых env не нужно. Документировать `EVOBGP_BUNDLE_SEED_HEX` как обязательно-стабильный для firewall-flow в `docs/access.md`. + +## Шаг 10 — Документация (DOC-01, RU) + +- `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. + +## Шаг 11 — Тесты (TEST-01/02) + +- `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 (опционально). + +## Порядок выполнения (после подтверждения плана) + +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`. + +## Ключевые файлы, которые будут затронуты + +- Создать: `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`. + +## Риски и компромиссы + +- **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