fix(daemon): update PID and timestamps in .codegraph/daemon.pid for synchronization

This commit is contained in:
Denozordec
2026-07-07 17:09:55 +07:00
parent 53b3c49612
commit 276194a9d0
2 changed files with 332 additions and 2 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 59836,
"pid": 40060,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783332305171
"startedAt": 1783395081742
}
@@ -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: <firewall_client_id>, 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: <hex>`. Сервер сверяет `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": "<uuid>",
"community_id": "<uuid>",
"revision_id": "<uuid>",
"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` — построчно `# <community>\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=<seed_hex> \
EVOBGP_COMMUNITY_ID=<uuid> \
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.