# 04 · Устройство

## Слои

```text
index.php ─ Router ─ Controller ─ Service ─ Fornex API
                       │            │
                       │            ├── ZonePlanner  (правила о записях)
                       │            └── DnsResolver  (проверка)
                       │
                       └── View (оболочка SPA)     SQLite: audit_events, snapshots,
                                                          api_tokens, login_attempts

backend/bin/fornex ─ Cli\Application ─ те же Services
```

Веб-вход и командная строка вызывают один `backend/bootstrap.php`, который возвращает `App` с
контейнером `Services`. Второй сборки и второй копии правил нет: пресет, применённый из
интерфейса, из скрипта или из `php backend/bin/fornex`, идёт одним путём
`PresetService` → `ZonePlanner` → `ZoneService`.

## Ядро

| Класс | Ответственность |
|---|---|
| `Kernel\Env` | читает `KEY=VALUE` из файла; настоящая переменная окружения важнее файла |
| `Kernel\Config` | неизменяемое дерево настроек, перекрытие по карте `ENV_MAP`, `path()` считает относительные пути от корня приложения |
| `Kernel\Request` | метод, путь, query, JSON-тело, bearer-токен, адрес клиента |
| `Kernel\Response` | `{ok:true, …}` / `{ok:false, error:{code, message, details}}` |
| `Kernel\Router` | табличные маршруты, `{name}` — один сегмент пути, 404 и 405 различаются |
| `Kernel\Session` | сессия в cookie, таймаут бездействия, регенерация при входе |
| `Kernel\Logger` | журнал с ротацией; секрет в него не попадает |
| `Kernel\App` | выполняет маршрут, применяет флаги `auth`/`csrf`/`write`, превращает исключения в ответы |

## Сервисы

| Сервис | Ответственность |
|---|---|
| `Fornex\Client` | HTTP к `ru.fornex.com/api`, браузерный `User-Agent`, повторы на сеть и 5xx, `FornexException` с кодом и сообщением |
| `Service\ZoneService` | читает зону, снимает снимок перед каждой записью, выполняет добавление/правку/удаление по `id` |
| `Service\ZonePlanner` | правила: дубли, слияние SPF, политика DMARC, форма плана (`create`, `update`, `skip`, `conflict`) |
| `Service\PresetService` | пресеты, шаги мастера, поля по шагу, нормализация DKIM, `stepsOverview` |
| `Service\VerifyService` | NS, MX и цели, SPF, DKIM по четырём селекторам, DMARC, A `@`/`www`, PTR — один отчёт |
| `Service\NsService` | состояние делегирования, инструкция регистратору, смена NS для домена, заказанного в Fornex |
| `Service\TokenService` | выпуск токена (префикс + хэш), проверка, отзыв; по умолчанию только чтение |
| `Service\AuditService` | запись и чтение `audit_events` |
| `Database\Database` | SQLite, миграции из `backend/migrations` |

## План

Любая запись начинается с плана:

```json
{
  "create":   [{"host": "@", "type": "MX", "value": "SMTP.GOOGLE.COM", "prio": 1, "why": "…"}],
  "update":   [{"id": 4237354, "from": "…", "to": "…", "why": "…"}],
  "skip":     [{"id": 4237363, "reason": "уже есть"}],
  "conflict": [{"id": 4232119, "reason": "MX на локальный mail мешает Google"}]
}
```

`preview` возвращает план и ничего не пишет. `apply` пересчитывает план по текущей зоне и
выполняет `create` и `update`; `skip` и `conflict` не применяются автоматически никогда. Снимок
снимается между этими шагами, поэтому план, который видел человек, и снимок на диске описывают
одну и ту же зону.

## Состояния шага мастера

`PresetService::stepsOverview` считает статус каждого шага:

| Статус | Значение | Что видит человек |
|---|---|---|
| `done` | запись, которую пишет шаг, уже в зоне (`present_if`) | «уже есть» |
| `todo` | шаг можно применять | активная кнопка |
| `empty` | шагу не хватает значения в поле | кнопка активна, сервер отвечает списком «чего не хватает» |
| `blocked` | не выполнен шаг из `requires` | предпросмотр выключен, назван недостающий шаг |

`empty` намеренно не отказ: шаг DKIM остаётся `empty`, пока не вставлен ключ, и запрет кнопки
на этом состоянии делал шаг невозможным — сначала нельзя было вставить ключ, потом получить план.

## Локальное состояние

В SQLite лежит только то, чего нет в зоне: `audit_events` (кто, что, был ли сухой прогон, итог),
`snapshots` (путь, sha256, число записей), `api_tokens` (префикс и хэш, никогда сам токен),
`login_attempts` (ограничение попыток входа). Сама зона читается из API при каждом запросе,
поэтому панель не может показать устаревшую копию.

## Проверка

`VerifyService` спрашивает два сервера на каждый тип записи: публичный резолвер
(`VERIFY_RESOLVER`) и авторитетный сервер Fornex (`VERIFY_AUTHORITATIVE`). В отчёте видно, где
записи действительно нет, а где ответ просто закеширован.
