---
name: Firewall HTTP blocklist feature
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 (postgres + sqlite, 4 файла): таблицы firewall_client + firewall_rule (client_id NULL = tenant-default) + down.sql по DEP-03"
    status: completed
  - id: store
    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: 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 (вкл. rules); npx @redocly/cli lint (TEST-05)
    status: completed
  - id: webui
    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) и DNS-failover retry
    status: completed
  - id: docs
    content: "Документация: docs/firewall.md (RU, раздел про rules + priorities) + правки access.md/api.md/architecture.md/remote-speakers.md"
    status: completed
  - id: tests
    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
---

# 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`. Подпись — 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://<agent_domain>/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 TB
  DNS["DNS: blocklist.example.com<br/>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]
    Sync[evobgp-firewall.sh по cron]
    Kernel[(ipset/nftables/iptables)]
  end
  subgraph cp [Control Plane]
    API[evobgp-api/all]
    PG[(PostgreSQL)]
    UI[Web UI /firewall]
  end
  subgraph sp [Speaker-agent]
    Agent[agentserver :8443]
    State["/var/lib/evobgp-agent/firewall-state.json"]
  end
  UI -->|"rules editor<br/>block/accept ordered"| API
  UI -->|"clients + overrides"| API
  API --> PG
  Sync -->|"GET /v1/firewall/blocklist<br/>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<br/>(clients + rules + prefixes)"| Agent
  Agent --> State
  Agent -->|"evaluated CIDR list"| Sync
  Sync --> Kernel
  Sync -->|"POST /v1/firewall/apply-report"| DNS
```

**Ключевые свойства:**

- **Один 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: <seconds>`.

## Модель 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<br/>EXCEPT trusted-cdn"]
  EvalB["Effective: block botnet, spam,<br/>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<br/>(один раз)"]
    Token["Client token<br/>(генерирует сервер)"]
    Approve["Operator approve<br/>(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=<bundle_seed_hex> \
  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=<uuid>
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: <uuid>. Ожидает 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)<br/>из 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.up.sql` / `.down.sql`
- `migrations/sqlite/000027_firewall.up.sql` / `.down.sql`

Схема (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,
    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';

-- 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;
```

`UNIQUE (tenant_id, client_id, priority)` — Postgres считает `NULL != NULL`, но `client_id IS NULL` у tenant-default уникален по логике «одна строка на tenant». Для гарантии на уровне приложения — проверять в store, что для tenant-default не может быть двух правил с одинаковым priority (так как NULL в UNIQUE не конфликтует).

SQLite — то же с поправкой на типы (`TEXT`/`BLOB`/`INTEGER`).

## Шаг 2 — Роль `firewall`

Изолированная роль (level 0, как `node`):

- `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: <firewall_client_id>, TenantID: ...}`.

## Шаг 3 — Store слой

В `internal/store/backend.go`:

- Структуры: `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)`
  - `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)`
- Методы правил:
  - `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).

Чистую функцию `EvaluateFirewallPolicy` вынести в `internal/firewall/policy.go` (новый пакет, без зависимостей от store/httpapi — легче тестировать). Это соответствует ARCH-08 (разделение слоёв).

## Шаг 4 — CP HTTP API

Новый файл `internal/httpapi/routes_firewall.go` + регистрация в `registerV1` (`routes.go:80`):

```go
// 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)

// 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: <hex>`. `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`) — policy-aware

- `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: <hash от rules>`.
- JSON-ответ:

```json
{
  "client_id": "<uuid>",
  "revision_id": "<uuid>",
  "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` — построчно для bash без jq.

### 4.3 `POST /v1/firewall/apply-report` (роль `firewall`)

- Body: `{status, error, prefix_count, ip_count, version, kernel_method, source}`.
- Сервер обновляет `last_apply_*` поля клиента (включая `last_apply_source`).

### 4.4 Operator endpoints

- `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

`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 в handlers — `s.firewallResolver.Reload(s.store)`.
- В `auth.go` `resolveAuth` — fallback на `firewallResolver.Lookup` если `apiKeyResolver` промахнулся.

## Шаг 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://<agent_domain>/v1/agent/firewall-replicate` с `Authorization: Bearer <agent_secret>` и payload:

```json
{
  "tenant_id": "<uuid>",
  "revision_id": "<last_revision_id>",
  "generated_at": "2026-07-07T16:00:00Z",
  "clients": [
    {"token_hash_hex": "<sha256>", "client_id": "<uuid>", "name": "edge-01"}
  ],
  "rules": [
    {"client_id": null, "priority": 1, "action": "block", "community_id": "<uuid-botnet>"},
    {"client_id": "<uuid>", "priority": 1, "action": "accept", "community_id": "<uuid-trusted>"}
  ],
  "communities": [{"id": "<uuid>", "community": "65000:100", "title": "drop-ads"}],
  "prefixes_by_community": {
    "<community_uuid>": ["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<br/>(локальный cache)"]
  Client[Linux firewall client] -->|"GET /v1/firewall/blocklist<br/>когда 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": "<uuid>",
  "revision_id": "<uuid>",
  "generated_at": "2026-07-07T16:00:00Z",
  "updated_at": "2026-07-07T16:05:00Z",
  "clients_by_hash": {
    "<sha256_hex>": {"client_id": "<uuid>", "name": "edge-01"}
  },
  "rules": [
    {"client_id": null, "priority": 1, "action": "block", "community_id": "<uuid-botnet>"},
    {"client_id": "<uuid>", "priority": 1, "action": "accept", "community_id": "<uuid-trusted>"}
  ],
  "prefixes_by_community": {
    "<community_uuid>": ["1.2.3.0/24", "5.6.7.8/32"]
  }
}
```

Atomic write через `rename(2)`. Mutex на чтение/запись.

### 8.3 `handleFirewallBlocklist` — policy-aware

1. `Bearer <token>` → 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: <seconds>`.
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`, `FirewallRule`, `FirewallRuleCreate`, `FirewallRulePatch`, `FirewallReorderRequest`, `FirewallEnrollRequest`, `FirewallEnrollResponse`, `FirewallBlocklist`, `FirewallApplyReport`, `FirewallReplicateRequest`.
- Параметр `X-EvoBGP-Seed` (header) для `/firewall/enroll`.
- Заголовки ответа `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).

## Шаг 10 — Web UI (`apps/web/`)

### Навигация и доступ

- 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 }` в группу «Операции».

### Макет страницы `/firewall`

```
┌─────────────────────────────────────────────────────────────────┐
│ 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 ${cp_url}/v1/firewall/install.sh | \
  EVOBGP_CP_URL=${cp_url} \
  EVOBGP_SEED=${seed} \
  EVOBGP_CLIENT_NAME="${client_name}" \
  bash
```

Если `seed` пустой — в preview placeholder `<вставьте_seed>` и кнопка «Копировать» disabled с tooltip.

**Prerequisites** (сворачиваемый `Collapsible`):

- Linux root, curl, bash
- nft / ipset / iptables
- Исходящий HTTPS к `${cp_url}`

### Таб «Клиенты»

Компонент: `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. 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`.

При `X-EvoBGP-Stale: true` — warning в лог, но применить (устаревший блок-лист лучше отсутствия).

### 11.3 `uninstall.sh`

- Снять systemd unit, убрать crontab, flush/destroy ipset/nft/iptables.

## Шаг 12 — Конфиг и env

CP: `EVOBGP_BUNDLE_SEED_HEX` уже есть. Новых env не нужно.

Speaker-agent:

- `EVOBGP_FIREWALL_FAILOVER_ENABLED=1`
- `EVOBGP_FIREWALL_STATE_FILE=/var/lib/evobgp-agent/firewall-state.json`

Документировать в `docs/access.md`, `docs/remote-speakers.md`.

## Шаг 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 файла) — `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.{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`

## Риски и компромиссы

- **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.
