# Fornex Control

**Самостоятельно размещаемая панель для DNS-зон Fornex: интерфейс, JSON API и командная строка — со снимком зоны перед каждой записью.**

<!-- md-i18n:switcher -->
**Читать на:** [English](README.md) · Русский

[![CI](https://github.com/paulmann/fornex-control/actions/workflows/ci.yml/badge.svg)](https://github.com/paulmann/fornex-control/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![PHP 8.5](https://img.shields.io/badge/php-8.5-777BB4)](https://www.php.net/)
[![Tests](https://img.shields.io/badge/tests-52%20passing-brightgreen.svg)](tests/plan_test.php)
[![API routes](https://img.shields.io/badge/API-27%20routes-6f42c1)](docs/API.md)
[![CLI commands](https://img.shields.io/badge/CLI-13%20commands-0ea5e9)](docs/CLI.md)

У Fornex есть публичный API для DNS, но зона — место, где неверная запись молчит: вторая SPF ломает
отправку со всего домена (`permerror`), вторая MX на ту же цель уводит половину писем мимо Google,
повторный запуск пресета копит дубли DKIM, а пропавшая запись обнаруживается не тестом, а
вернувшимся письмом через недели. Панель превращает работу с зоной в проверяемый план: **сначала
предпросмотр, потом применение, снимок перед каждой записью** — и одни и те же правила для
интерфейса, JSON API и командной строки, потому что все они идут через один планировщик.

> **Состояние документации.** Все числа в этом README измерены на опубликованном дереве (`52` проверки в `tests/plan_test.php`, `27` маршрутов в `backend/config/routes.php`, `13` команд CLI в `backend/src/Cli/Application.php`). Подробные руководства — в [Wiki](https://github.com/paulmann/fornex-control/wiki) и в [`docs/README.md`](docs/README.md).

Проект не связан с Fornex. «Fornex» — товарный знак соответствующего владельца.

---

## Содержание

- [Что это и зачем](#что-это-и-зачем)
- [Возможности](#возможности)
- [В числах](#в-числах)
- [Как устроено](#как-устроено)
- [Требования](#требования)
- [Установка](#установка)
- [Быстрый старт](#быстрый-старт)
- [Настройки](#настройки)
- [Командная строка](#командная-строка)
- [Пресеты почты](#пресеты-почты)
- [Правила безопасности](#правила-безопасности)
- [Ограничения самого Fornex](#ограничения-самого-fornex)
- [Документация](#документация)
- [Разработка](#разработка)
- [Частые вопросы](#частые-вопросы)
- [Участие](#участие)
- [Лицензия](#лицензия)

---

## Что это и зачем

Зону правят три вида участников: человек в браузере, скрипт по расписанию и агент, отвечающий на
заявку. Когда у каждого свой код, правила расходятся: интерфейс отказывается создавать вторую SPF,
скрипт создаёт, и почта перестаёт приходить. Здесь правила живут в одном месте —
`backend/src/Service/ZonePlanner.php`, — а всё остальное их вызывает.

- **Инвентарь.** Зоны аккаунта, их записи, состояние NS, полнота делегирования.
- **Записи.** A, AAAA, CNAME, MX, TXT, NS, SRV, CAA с проверкой по типу; удаление строго по числовому `id`; `SOA` и `NS` не трогаются никогда.
- **Пресеты.** Google Workspace (4 шага), Яндекс 360, сайт на этом VPS, почта на этом VPS — со слиянием SPF/DMARC вместо перезаписи.
- **Проверка.** NS у регистратора, MX и их цели, SPF, DKIM по четырём селекторам, DMARC, A для `@`/`www`, PTR — с опросом и публичного резолвера, и авторитетного сервера Fornex.
- **Журнал.** Каждая попытка, сухой прогон и отказ попадают в журнал с причиной; снимки хранятся как BIND + JSON.
- **То же по HTTP.** 27 маршрутов API; токен может быть «только чтение», и маршрут предпросмотра доступен даже такому токену.

Ключевые решения:

| Решение | Почему |
|---|---|
| Ни одной зависимости Composer и npm | установка — `git clone` плюс PHP; ничего не ломается на транзитивном пакете |
| Предпросмотр → применение, снимок перед записью | ошибка в зоне не видна в момент записи |
| SPF/DMARC/DKIM сливаются, а не заменяются | в зоне часто уже есть механизмы, о которых пресет не знает |
| Удаление только по числовому `id` | маска или шаблон задевает запись, которую никто не собирался менять |
| Один планировщик для интерфейса, API и CLI | три входа не могут разойтись в правилах |
| SQLite для локального состояния | один узел; сама зона остаётся в Fornex, панель хранит только журнал, токены и снимки |

## Возможности

| Возможность | Подробности |
|---|---|
| Просмотр и правка зоны | записи с `id`, типом, хостом, значением, приоритетом и TTL; запись всегда через план |
| Мастер почты из четырёх шагов | подтверждение → MX/SPF → DKIM → DMARC, каждый шаг применяется отдельно и заблокирован до выполнения предыдущего |
| Слияние SPF | существующие механизмы `include:`, `ip4:`, `a`, `mx` сохраняются; пресет добавляет, но не выбрасывает |
| DMARC не ослабляется | `p=quarantine` или `p=reject`, уже стоящие в зоне, никогда не понижаются до `none` |
| Нормализация DKIM | к `p=…` добавляется префикс `v=DKIM1; k=rsa;`, переносы строк и пробелы внутри `p=` удаляются |
| Подтверждение именем домена | шаги, заменяющие ключ подписи или строку подтверждения, требуют ввести имя домена; шаги, которые только добавляют записи, — нет |
| Снимок зоны | BIND-файл и JSON с идентификаторами записей, с sha256, перед каждым изменением |
| Проверка DNS | публичный резолвер плюс `ns1.fornex.com`, чтобы кешированный ответ не был принят за истину |
| API с токенами | по умолчанию только чтение, запись — если токен выпущен с этим правом; для записи из браузера нужен `X-CSRF-Token` |
| Командная строка | 13 команд, по умолчанию сухой прогон, запись только с `--apply` |
| Журнал действий | кто, что, над чем, был ли это сухой прогон, итог, идентификатор запроса |

## В числах

| Что | Значение | Откуда |
|---|---|---|
| Файлов PHP | 45 | `Get-ChildItem -Recurse -Filter *.php` |
| Строк PHP, JS, CSS, JSON, SQL | ~6 965 | то же дерево, `Get-Content \| Measure-Object -Line` |
| Маршрутов API | 27 | `backend/config/routes.php` |
| Команд CLI | 13 | `backend/src/Cli/Application.php` |
| Проверок в тестах | 52 | `php tests/plan_test.php` |
| Зависимостей времени выполнения | 0 | нет `composer.json`, нет `package.json` |
| Пресетов | 4 (один из них — мастер из 4 шагов) | `backend/config/presets.json` |

## Как устроено

```text
browser  ──►  index.php  ──►  Router  ──►  Controller  ──►  Service  ──►  Fornex API
   │                                        │                 │
   │  SPA (assets/js/app.js, без сборки)    │                 └── ZonePlanner (правила)
   └───────────────────────────────────────┴──► SQLite: журнал, токены, снимки

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

Зона никогда не хранится локальной копией: она читается из Fornex API на каждый запрос, поэтому
панель показывает то, что вернул API. Локальное состояние — только журнал, токены API, попытки входа
и снимки.

## Требования

- PHP 8.5 с расширениями `curl`, `json`, `mbstring`, `openssl`, `pdo_sqlite`. На этом дереве исполнялся только 8.5; матрица CI — 8.5.
- Аккаунт Fornex с ключом Public API (`ru.fornex.com` → «Мой» → API).
- Любой веб-сервер, умеющий PHP (проект разрабатывался под nginx + PHP-FPM; для осмотра хватает `php -S`).

## Установка

```bash
# 1. Файлы — вне или внутри корня сайта, но настройки всегда вне него
git clone https://github.com/paulmann/fornex-control.git /srv/fornex-control

# 2. Настройки уровнем выше приложения
install -m 640 /srv/fornex-control/.env.example /srv/fornex.env
$EDITOR /srv/fornex.env      # FORNEX_API_KEY, ADMIN_PASSWORD_HASH
php -r "echo password_hash('ваш-пароль', PASSWORD_DEFAULT), PHP_EOL;"

# 3. Локальное состояние (SQLite, журнал, снимки) — создаётся при первом запуске, нужен доступ на запись
install -d -m 750 /srv/fornex-control/var /srv/fornex-control/var/log /srv/fornex-control/var/snapshots
chown -R www-data:www-data /srv/fornex-control/var

# 4. Проверить до публикации
php backend/bin/fornex help
php tests/plan_test.php
```

nginx (здесь панель живёт в подкаталоге, поэтому `APP_BASE=/fornex`):

```nginx
location ^~ /fornex/ {
    alias /srv/fornex-control/;
    index index.php;

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $request_filename;
    }

    location ~ ^/fornex/(backend|tests|tools|var)/ { deny all; }
    location ~ /\. { deny all; }
}
```

- `docs/DEPLOY.md` — полный рецепт, включая заголовок `Authorization` и CSRF.
- `docs/TROUBLESHOOTING.md` — что означает каждый симптом.

## Быстрый старт

```bash
# временный экземпляр на localhost, веб-сервер не нужен
cp .env.example .env                    # FORNEX_API_KEY=..., DB_PATH=var/fornex.sqlite
php -S 127.0.0.1:8080 index.php         # http://127.0.0.1:8080/

# нет ключа под рукой? help и preset:list работают без него
php backend/bin/fornex preset:list
```

## Настройки

Каждый ключ, значение по умолчанию и его эффект: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). Четыре главных:

| Ключ | По умолчанию | Действие |
|---|---|---|
| `FORNEX_API_KEY` | — | без него панель не запускается |
| `ADMIN_PASSWORD_HASH` | пусто | пустой хэш — вход запрещён; получается через `password_hash()` |
| `AUTO_LOGIN_IP` | пусто | вход без пароля с одного адреса; оставлять пустым, если сеть не ваша |
| `DB_PATH`, `LOG_PATH`, `SNAPSHOT_DIR` | `var/…` | относительные пути считаются от корня приложения |

Секреты не попадают в браузер: интерфейс получает только CSRF-токен. Ключ API и хэш пароля не отдаёт
ни один маршрут — `GET /api/meta` показывает адрес API, таймауты и типы записей.

## Командная строка

```bash
php backend/bin/fornex domains                       # зоны аккаунта
php backend/bin/fornex zone:show example.com         # записи зоны
php backend/bin/fornex zone:snapshot example.com     # снимок, без записи
php backend/bin/fornex preset:apply example.com google-workspace --param:dkim='v=DKIM1; k=rsa; p=…'
php backend/bin/fornex preset:apply example.com google-workspace --param:dkim='v=DKIM1; k=rsa; p=…' --apply
php backend/bin/fornex verify example.com            # NS, MX, SPF, DKIM, DMARC, PTR
php backend/bin/fornex ns:status example.com         # делегирование и инструкция регистратору
```

Изменяющие команды — сухой прогон: печатают план и выходят с кодом `0`, ничего не записывая. `--apply` выполняет изменение, и снимок снимается до него.

## Пресеты почты

`google-workspace` — мастер из четырёх шагов, потому что Google выдаёт ключ DKIM только после подтверждения домена:

| Шаг | Что пишет | Подтверждение | Чем заблокирован |
|---|---|---|---|
| 1. Подтверждение | TXT `@` `google-site-verification=…` | именем домена | — |
| 2. Почта | MX 1, MX 5, MX 10 (`SMTP.GOOGLE.COM`), SPF | нет | шагом 1 |
| 3. DKIM | TXT `google._domainkey` | именем домена | шагом 1 и самим ключом |
| 4. DMARC | TXT `_dmarc` | нет | — |

На шаге 2 три MX на одну цель стоят намеренно: Fornex отвергает вторую MX с идентичной строкой
значения, поэтому записи с приоритетами 5 и 10 отличаются точкой на конце и регистром букв — для DNS
это тот же хост.

## Правила безопасности

1. **Сначала предпросмотр, потом применение.** `POST /api/domains/{domain}/preset/{preset}/preview` — маршрут только на чтение; интерфейс никогда не пишет прямо из формы.
2. **Снимок перед записью.** BIND-файл и JSON с идентификаторами записей попадают в `SNAPSHOT_DIR` вместе с sha256; запись журнала называет снимок.
3. **Удаление по `id`.** Никаких масок и «все TXT с таким префиксом».
4. **`SOA` и `NS` не редактируются.** Они держат зону.
5. **Пустое поле — не отказ.** Шаг с незаполненным полем отвечает `200` и списком «чего не хватает», а не ошибкой; кнопку выключает только состояние `blocked`.
6. **Отказ объясняется.** Ответ `403` от Fornex про создание зон показывается как готовый текст тикета в поддержку, а не как общий сбой.
7. **Секреты — только именами.** Отчёты, журнал и интерфейс показывают имя, маску и отпечаток, никогда значение.

## Ограничения самого Fornex

| Ограничение | Что делает панель |
|---|---|
| Создание зоны через API на аккаунтах с выключенным правом возвращает `403` | показывает ссылку в кабинет и готовый текст тикета; записи, пресеты и проверки работают, как только зона появилась |
| Вторая MX с той же строкой значения отвергается (`400 … already exists`), приоритет не учитывается | пресет ставит лишнюю MX с точкой на конце и другим регистром |
| Запросы без браузерного `User-Agent` подвисают на TLS | клиент всегда его отправляет |
| Зона пересобирается минутами, резолверы кешируют ответ | проверка спрашивает `ns1.fornex.com`, а не кеш |
| NS меняются только у доменов, заказанных в Fornex | иначе панель печатает инструкцию регистратору |

## Документация

| Документ | Содержимое |
|---|---|
| [Wiki](https://github.com/paulmann/fornex-control/wiki) | подробные руководства, 13 страниц; исходники в [`wiki/`](wiki/wiki.json) |
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | ядро, сервисы, путь запроса, где живут правила |
| [`docs/API.md`](docs/API.md) | все маршруты, авторизация, CSRF, форматы ошибок |
| [`docs/CLI.md`](docs/CLI.md) | все команды и их ключи |
| [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) | все переменные окружения со значениями по умолчанию |
| [`docs/DEPLOY.md`](docs/DEPLOY.md) | nginx, PHP-FPM, права, заголовки |
| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | раскладка, тесты, как добавить пресет |
| [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) | симптом → причина → действие |
| [`AGENTS.md`](AGENTS.md) | правила работы агента с этим деревом |

## Разработка

```bash
php tools/lint.php          # синтаксис PHP плюс BOM/CRLF/финальный перевод строки для файлов кода
php tools/lint.php --text    # то же для markdown и dotfile-ов
php tests/plan_test.php      # 52 проверки, без сети и без базы
php tools/secret-scan.php    # падает на строке, похожей на учётную запись
```

Набор тестов покрывает ровно те правила, чья ошибка молчит: повторный пресет не должен копить дубли
SPF/DMARC/DKIM, MX на ту же цель с другим приоритетом должен правиться, а не создаваться второй раз,
слияние SPF обязано сохранить сторонний `ip4:`, DMARC не должен ослабляться, а каждый шаг мастера —
правильно показывать `done`/`todo`/`blocked`/`empty`.

## Частые вопросы

**Работает ли без ключа API?** Нет — панель отказывается запускаться; ключ это единственный способ
прочитать зону. `help` и `preset:list` работают, чтобы установку можно было проверить.

**Может ли она создать зону?** Только если аккаунт Fornex это разрешает. Когда право выключено, API
отвечает `403`, и панель объясняет, что отправить в поддержку, вместо молчаливого сбоя.

**Где снимки?** В `SNAPSHOT_DIR` (по умолчанию `var/snapshots/<домен>/`): файл `.bind` и `.json` с
идентификаторами записей. Автоматически они не восстанавливаются: возврат зоны — решение человека по
снимку.

**Интерфейс переведён?** Интерфейс на русском; API, командная строка, документация и этот README — на английском.

## Участие

Отчёты об ошибках и патчи приветствуются — [`CONTRIBUTING.md`](CONTRIBUTING.md). Проблемы
безопасности — приватно, по [`SECURITY.md`](SECURITY.md), не в issue. Изменения записываются в
[`CHANGELOG.md`](CHANGELOG.md) в формате Keep a Changelog.

## Лицензия

MIT — см. [`LICENSE`](LICENSE).
