DNS-зона — место, где ошибка не падает с исключением. Вторая запись SPF ломает всю почту домена, второй MX на ту же цель уводит половину писем, повторный запуск пресета дублирует DKIM, а пропавшая запись обнаруживается через неделю по bounced-письму. Fornex Control 1.1.0 — self-hosted панель для DNS-зон Fornex, построенная вокруг одного правила: сначала предпросмотр плана, потом запись, и снимок зоны перед каждым изменением. Ниже — что внутри, что показали прогон тестов и проверка живого экземпляра, и на чём спотыкаются при первом развёртывании.

fornex control cover

TL;DR: что это и как проверить

  1. Что это. Панель над публичным API Fornex: веб-интерфейс, JSON API и CLI — все три через один планировщик правил.
  2. Главное правило. Изменение всегда сначала печатается планом; запись — вторым шагом, с --apply.
  3. Снимок перед записью. BIND-файл и JSON с идентификаторами записей, плюс sha256; запись журнала называет снимок.
  4. Почта Google в четыре шага. Проверка → MX/SPF → DKIM → DMARC, каждый шаг отдельно и заблокирован до предыдущего.
  5. Стек. PHP 8.5, SQLite, ноль зависимостей: ни composer.json, ни package.json в дереве.
php backend/bin/fornex domains                 # зоны аккаунта
php backend/bin/fornex zone:show example.com    # записи
php backend/bin/fornex verify example.com       # NS, MX, SPF, DKIM, DMARC, PTR
php backend/bin/fornex audit --limit=30         # журнал

Проблема, которую это решает

У Fornex есть публичный API для DNS, и технически зону можно править чем угодно — хоть curl, хоть панелью провайдера. Проблема в том, что зона не прощает небрежности, и цена ошибки отложена.

Три типичных сценария. Первый: добавили SPF-запись, а старая осталась — теперь у домена две SPF, и по стандарту это permerror, то есть письма не проходят вовсе. Второй: для Google нужны три MX с одинаковой целью, но Fornex отклоняет вторую запись с тем же значением — и человек правит руками, получая то одну запись, то две. Третий: DKIM-ключ вставлен из письма кусками, с переносами строк и пробелами внутри p= — запись попадает в зону невалидной, и подпись не проверяется.

Панель делает эти сценарии проверяемыми заранее: план показывает, что именно изменится, слияние SPF сохраняет существующие механизмы, DMARC не понижается, а ключ приводится к нормальному виду автоматически.

Что такое Fornex Control

flowchart TB
  WEB["браузер: интерфейс без сборки"] --> RT["index.php и роутер"]
  RT --> CTRL["контроллер"]
  CTRL --> SVC["сервисы"]
  CLI["CLI: 13 команд"] --> SVC
  API["JSON API: 27 маршрутов"] --> RT
  SVC --> PLAN["ZonePlanner: правила зоны"]
  SVC --> API2["API Fornex"]
  SVC --> DB["SQLite: журнал, токены, снимки"]
Свойство Значение
Тип self-hosted панель над API Fornex
Стек PHP 8.5, SQLite, интерфейс без сборки
Зависимости ноль: ни composer, ни npm
Входы браузер, JSON API, CLI
Маршрутов API 27
Команд CLI 13, изменение только с --apply
Тесты 52 проверки в tests/plan_test.php
Лицензия MIT

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

Возможности

Работа с зоной

Что Как работает
Просмотр записей id, тип, хост, значение, приоритет, TTL
Правка только через план: сначала предпросмотр, потом применение
Удаление по id, без масок и «все TXT с таким префиксом»
SOA и NS не редактируются: на них держится зона
Снимок BIND-файл плюс JSON с id записей и sha256 перед каждой записью
Журнал кто, что, цель, был ли это dry-run, результат, идентификатор запроса

Почтовый мастер

flowchart TD
  S1["шаг 1: TXT проверки"] --> S2["шаг 2: MX 1/5/10 и SPF"]
  S2 --> S3["шаг 3: DKIM google._domainkey"]
  S3 --> S4["шаг 4: DMARC"]
  S2 --> SKIP["слияние SPF: include, ip4, a, mx сохраняются"]
  S3 --> NORM["нормализация ключа: префикс v=DKIM1; k=rsa;"]
  S4 --> KEEP["p=quarantine и p=reject не понижаются"]
Шаг Что пишет Подтверждение
1. Проверка TXT @ с google-site-verification=… ввод имени домена
2. Почта MX 1, MX 5, MX 10 и SPF не требуется
3. DKIM TXT google._domainkey ввод имени домена
4. DMARC TXT _dmarc не требуется

Подтверждение именем стоит там, где данные заменяются, — на ключе подписи и строке проверки. Там, где записи только добавляются, лишнего шага нет, и это осмысленно: подтверждение ради подтверждения быстро превращается в привычку жать «ок».

Правила, встроенные в планировщик

Правило Что делает
Слияние SPF сохраняет существующие include:, ip4:, a, mx и добавляет недостающие
Без изменений — не обновление если результат совпал с текущим, это пропуск, а не правка
DMARC не ослабляется p=quarantine и p=reject никогда не понижаются до none
Нормализация DKIM к голому p=… добавляется v=DKIM1; k=rsa;, переносы и пробелы внутри p= убираются
Третий MX приоритет 10 с точкой в конце и иным регистром, потому что Fornex отвергает дубль значения

Безопасность и доступ

Что Как работает
Токены API только чтение по умолчанию, запись — если выдан с флагом
CSRF X-CSRF-Token для записей из браузера
Предпросмотр доступен и токену только для чтения
Секреты в отчётах и логах только имя, маска и отпечаток, никогда значение
Отказы Fornex 403 про создание зоны показывается как готовый текст тикета, а не «ошибка»

Как это работает

flowchart LR
  REQ["запрос изменения"] --> PRV["предпросмотр плана"]
  PRV --> CONF{"шаг требует подтверждения именем?"}
  CONF -- да --> NAME["ввод домена"]
  CONF -- нет --> SNAP
  NAME --> SNAP["снимок зоны: BIND, JSON, sha256"]
  SNAP --> WR["запись в API Fornex"]
  WR --> AUD["запись в журнал: кто, что, результат"]

Все три входа — браузер, API и CLI — идут через одни и те же сервисы и один планировщик ZonePlanner. Именно поэтому одинаково ведут себя и кнопка в интерфейсе, и команда в консоли: правила живут в одном месте, а не дублируются в трёх.

Слой Ответственность
index.php и роутер маршрутизация, разбор запроса
Контроллеры валидация входа, ответы и коды
Сервисы сценарии: зона, пресеты, проверка, журнал
ZonePlanner правила: слияние SPF, нормализация DKIM, защита DMARC
Клиент Fornex HTTP к API, повторные попытки, разбор отказов
SQLite журнал, токены, попытки входа, снимки

Замеры и цифры

Проверено 10.11.2026: прогон дерева релиза и осмотр живого развёртывания.

Что проверено Значение Как
Архив исходников v1.1.0 171 357 байт, SHA-256 de5c103a…679b47 скачивание тега и подсчёт хеша
Тесты 52 проверки, все пройдены php tests/plan_test.php
Версия PHP при прогоне 8.5.0 (cli, NTS) php -v
Команд CLI 13 вывод php backend/bin/fornex без аргументов
Маршрутов API 27 подсчёт в backend/config/routes.php
PHP-файлов в дереве 45 (по README) —
Строк кода ~6 965 (по README) —
Живой экземпляр развёрнута копия 1.1.0, снаружи 403 defaults.php и curl
Fornex Control 1.1.0: что подтвердилось прогоном

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

Чего замеры не показывают: реальную запись в зону. Для неё нужен ключ API Fornex и живая зона, а трогать боевые записи ради статьи смысла нет. Также не проверялись интерфейс и маршруты API на живом экземпляре: снаружи панель закрыта (403), а локальный запрос по хосту отдаёт 404 — доступ настроен под конкретный адрес, и разбираться в чужой конфигурации nginx я не стал.

Сильные стороны продукта

  • План до записи. Один и тот же предпросмотр для интерфейса, API и CLI.
  • Снимок перед изменением. BIND и JSON с id записей плюс sha256, а не «мы помним, что было».
  • Слияние SPF, а не перезапись. Существующие механизмы выживают, добавляются только недостающие.
  • DMARC нельзя ослабить. p=quarantine и p=reject не превращаются в none повторным запуском.
  • Нормализация DKIM. Ключ, вставленный кусками, становится валидной записью.
  • Ноль зависимостей. PHP 8.5 и SQLite: развернуть можно на любом хостинге с PHP.
  • Токены с двумя правами. Чтение по умолчанию, запись — осознанно и с CSRF.
  • Честные ограничения Fornex. Лимиты провайдера не спрятаны, а описаны вместе с обходом.

Установка

git clone https://github.com/paulmann/fornex-control.git /srv/fornex-control

# конфигурация — уровнем выше приложения, не в веб-корне
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('your-password', PASSWORD_DEFAULT), PHP_EOL;"

install -d -m 750 /srv/fornex-control/var /srv/fornex-control/var/log /srv/fornex-control/var/snapshots
Требование Зачем
PHP 8.5 с curl, json, mbstring, openssl, pdo_sqlite весь стек без композера
Ключ публичного API Fornex чтение и запись зон
Каталог var/ с правами на запись SQLite, журнал, снимки
Веб-сервер с PHP nginx с FPM или даже php -S для осмотра

Две вещи, которые стоит сделать до первого запуска: вынести .env за пределы веб-корня и проверить, что var/snapshots пишется. Снимок зоны — единственный способ откатиться, если план окажется не тем, что вы ожидали.

Настройка

Что Где задаётся
Ключ API Fornex FORNEX_API_KEY в .env
Пароль администратора ADMIN_PASSWORD_HASH, хеш из password_hash
Каталог снимков SNAPSHOT_DIR
Пресеты backend/config/presets.json
Порог журнала --limit в команде audit
Токены выдаются через интерфейс, право записи — флагом

Пресеты вынесены в JSON не случайно: новый сценарий (например, почта другого провайдера) — это описание шагов и параметров, а не правка кода. Шаг объявляет, нужно ли для него подтверждение именем, поэтому поведение интерфейса следует за данными.

Проверка после установки

Шаг Команда Признак успеха
Виден ли аккаунт fornex domains список зон
Читается ли зона fornex zone:show <домен> записи с id
Работает ли снимок fornex zone:snapshot <домен> файл в var/snapshots
Видны ли пресеты fornex preset:list четыре пресета
Проверка DNS fornex verify <домен> отчёт по NS, MX, SPF, DKIM, DMARC, PTR
Журнал fornex audit --limit=5 записи с идентификаторами запросов

Порядок именно такой: сначала чтение, потом снимок, и только затем команды с --apply. Если domains не отвечает — проблема в ключе, а не в панели.

Совместимость и ограничения Fornex

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

Показательные случаи

Что видно в работе Причина Что делать
domains возвращает пустой список ключ не от того аккаунта или лишён права проверить ключ в кабинете Fornex
403 при создании зоны право выключено на аккаунте заказать зону в кабинете, панель заработает с ней
400 … already exists на MX Fornex сравнивает значение, а не приоритет не плодить записи руками, дать пресету сделать своё
verify показывает старые данные ответ взят из кеша резолвера проверять через ns1.fornex.com
кнопка шага неактивна предыдущий шаг не завершён смотреть состояние шагов, а не гадать
шаг вернул 200 и текст о нехватке поля пустое значение — не отказ заполнить то, что названо в ответе

Почему сниппет зоны важнее кнопки «отменить»

В DNS не бывает отката в один клик: TTL, кеши резолверов и время пересборки зоны делают любое изменение отложенным. Поэтому ставка сделана не на отмену, а на предсказуемость.

Подход Когда помогает Где не спасает
Кнопка «отменить» сразу после ошибки, пока ничего не ушло в кеш через минуту уже бесполезна
Снимок зоны перед записью всегда: есть точное состояние до изменения требует ручного возврата записей по id
Предпросмотр плана до записи: видно, что именно изменится не заменяет проверку после
Проверка по ns1.fornex.com после записи: видно фактическое состояние нужно повторить через несколько минут

Из этой таблицы и складывается рабочий порядок: план → снимок → запись → проверка у авторитетного сервера → запись в журнал. Шаг «снимок» не роскошь: это единственный источник истины о том, как было.

Интерфейс, API и CLI — одно поведение

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

Ситуация Интерфейс CLI
Посмотреть план пресета предпросмотр в мастере preset:apply без --apply
Применить шаг кнопка после проверки плана та же команда с --apply
Посмотреть журнал страница аудита audit --limit=N
Проверить DNS отдельный экран verify <домен>
Снимок зоны перед каждой записью автоматически zone:snapshot вручную

Практический смысл: можно вести диагностику из консоли, не боясь, что план разойдётся с тем, что сделала бы кнопка. И наоборот — действие из браузера попадает в тот же журнал, что и команда из скрипта.

Отдельно про SPA без сборки: один app.js без бандлера — это осознанный выбор в пользу простоты развёртывания. Плата — нет модульности фронтенда и нет типизации; выигрыш — нет node_modules на сервере и нет шага сборки при обновлении.

Подводные камни

  • Конфигурация в веб-корне. .env с ключом API и хешем пароля должен лежать уровнем выше приложения, иначе ключ доступен через браузер.
  • Снимок не откат. Файл в var/snapshots — это материал для восстановления, а не автоматический откат; применять его придётся руками.
  • Второй MX и «одинаковое значение». Fornex сравнивает строку значения без учёта приоритета, поэтому трюк с точкой и регистром — не хитрость, а необходимость.
  • Кеш резолверов врёт. После записи проверять через ns1.fornex.com, иначе легко решить, что запись не применилась.
  • Пустое поле — не ошибка. Шаг с пустым значением вернёт 200 и текст «чего не хватает»; блокирует кнопку только состояние blocked.
  • SOA и NS не редактируются. Это защита: правка этих записей ломает зону целиком.
  • Токен только для чтения не запишет. Для изменений нужен токен с флагом записи, а из браузера ещё и X-CSRF-Token.
  • Только PHP 8.5. Дерево исполнялось на 8.5, CI тоже 8.5; на 8.3 могут всплыть синтаксические мелочи.
  • 403 при создании зоны — не поломка панели. Это ограничение аккаунта Fornex; обходится заказом зоны в кабинете.
  • Интерфейс без сборки — это плюс и минус. Никакого npm build, но и модульности фронтенда не ждите: один app.js.

Чек-лист

  • [ ] Проверить PHP 8.5 и расширения curl, json, mbstring, openssl, pdo_sqlite.
  • [ ] Скопировать .env.example уровнем выше приложения и заполнить ключ и хеш пароля.
  • [ ] Создать var, var/log, var/snapshots с правами на запись.
  • [ ] Проверить чтение: fornex domains и zone:show.
  • [ ] Сделать снимок зоны до любых изменений.
  • [ ] Прогнать preset:apply без --apply и прочитать план.
  • [ ] Применить шаг 1 (проверка), затем дождаться подтверждения у провайдера.
  • [ ] Повторить для шагов 2–4, каждый раз читая план.
  • [ ] Проверить результат через fornex verify по ns1.fornex.com.
  • [ ] Убедиться, что в журнале есть записи с идентификаторами запросов.

FAQ

Зачем панель, если есть API?

API не мешает ошибиться: он принимает вторую SPF-запись и вторую MX с тем же значением. Панель добавляет правила — слияние SPF, нормализацию DKIM, защиту DMARC — и предпросмотр до записи.

Что именно сохраняется в снимке?

BIND-файл зоны и JSON с идентификаторами записей, плюс sha256 содержимого. Запись в журнале называет снимок, так что связь «изменение — состояние до него» восстанавливается.

Почему для двух шагов нужно вводить имя домена?

Эти шаги заменяют данные: ключ подписи и строку проверки. Подтверждение именем не даёт заменить ключ случайным кликом. Шаги, которые только добавляют записи, такого требования не имеют.

Что будет, если запустить пресет повторно?

Ничего разрушительного: совпавший результат считается пропуском, SPF сливается с существующим, DMARC не понижается, дубликат DKIM нормализуется и не создаёт вторую запись.

Можно ли хранить зону локально и работать без сети?

Нет, и это осознанно: зона читается из API на каждый запрос, чтобы панель не показывала устаревшую копию. Локально живут только журнал, токены и снимки.

Нужен ли отдельный сервер?

Достаточно любого хостинга с PHP 8.5 и SQLite. Для осмотра хватает php -S, но для постоянной работы — nginx с FPM и .env вне веб-корня.

Что было в релизе 1.1.0 и почему это первый публичный выпуск

Дерево, опубликованное как 1.1.0, — это код, который работал приватной панелью (внутренние версии 1.0.0 → 1.1.0), с удалёнными значениями конкретной установки: имена хостов, адреса, абсолютные пути и заглушка вместо ключа API.

Что добавилось к 1.0.0 Почему это важно снаружи
четырёхшаговый мастер почты сценарий Google не сводится к одной кнопке
подтверждение именем только там, где данные заменяются лишние шаги не превращаются в рефлекс
слияние SPF чужие механизмы в зоне не теряются
защита DMARC от понижения повторный запуск не ломает политику
нормализация DKIM ключ из письма кусками становится валидным
снимок и журнал появляется след и точка возврата
токены с двумя правами автоматизация без права писать

Для читателя важно одно: это не «панель для себя», переписанная под публикацию, а работающий код с удалёнными приватными значениями. Отсюда и честные ограничения в документации: где Fornex мешает, там это написано, а не спрятано.

Второе следствие — установочные значения пришлось вынести в конфигурацию. Поэтому ключ API и хеш пароля живут в .env уровнем выше приложения, а не в файлах дерева: в приватной панели такого разделения можно было не делать.

Итог

Fornex Control 1.1.0 — редкий случай, когда панель над чужим API добавляет не удобство, а дисциплину: план до записи, снимок перед изменением, правила, которые нельзя случайно обойти кнопкой. Проверка подтвердила главное, что проверяется без ключа: 52 теста проходят, CLI показывает 13 команд, маршрутов 27, зависимостей ноль, а живой экземпляр развёрнут версией 1.1.0 и закрыт снаружи. Ограничения честные: только PHP 8.5, запись в зону требует ключа Fornex, снимок — материал для ручного откката, а не кнопка «вернуть как было».

Цитата: В DNS опасна не ошибка, а её отложенность — поэтому единственная полезная кнопка там называется «покажи план».

Связанные материалы

LLM Summary

  • Тема: Fornex Control 1.1.0 — self-hosted панель для DNS-зон Fornex: веб-интерфейс, JSON API и CLI над одним планировщиком правил.
  • Ключевой принцип: предпросмотр плана, затем запись; снимок зоны (BIND, JSON с id записей, sha256) перед каждым изменением; запись журнала называет снимок.
  • Стек: PHP 8.5, SQLite, ноль зависимостей (нет composer.json и package.json), 45 PHP-файлов, около 6 965 строк.
  • Поверхность: 27 маршрутов API, 13 команд CLI, 4 пресета, 52 проверки в tests/plan_test.php.
  • Почтовый мастер: четыре шага — проверка (TXT), MX 1/5/10 и SPF, DKIM, DMARC; подтверждение именем домена только на шагах замены данных; шаг заблокирован до выполнения предыдущего.
  • Правила: слияние SPF сохраняет include:, ip4:, a, mx; совпавший результат — пропуск; DMARC p=quarantine/p=reject не понижается; DKIM нормализуется (префикс v=DKIM1; k=rsa;, удаление переносов и пробелов в p=).
  • Проверено 10.11.2026: архив тега 171 357 байт, SHA-256 de5c103a…679b47; php tests/plan_test.php — 52 проверки пройдены на PHP 8.5.0; CLI перечисляет 13 команд; в backend/config/routes.php 27 маршрутов; живой экземпляр развёрнут версией 1.1.0 и снаружи отдаёт 403.
  • Не проверялось: реальная запись в зону (нужен ключ API Fornex), интерфейс и маршруты API на живом экземпляре (доступ закрыт), поведение на PHP ниже 8.5.
  • Ограничения Fornex: создание зоны через API может давать 403; второй MX с тем же значением отклоняется; запрос без User-Agent зависает; кеш резолверов требует проверки через ns1.fornex.com.
  • Ссылки: https://github.com/paulmann/fornex-control, https://github.com/paulmann/fornex-control/releases/tag/v1.1.0

Промт для агента (установка и настройка)

Готовый промт: скопируйте блок целиком. Шаги взяты из AGENTS.md и README.md репозитория (файл AGENTS.md в дереве есть) и сверены с прогоном тестов.

Задача: развернуть Fornex Control 1.1.0, настроить почту Google в четыре шага и не сломать существующую зону.

Материалы:
- репозиторий: https://github.com/paulmann/fornex-control
- AGENTS.md: https://github.com/paulmann/fornex-control/blob/main/AGENTS.md
- README: https://github.com/paulmann/fornex-control/blob/main/README.md
- релиз: https://github.com/paulmann/fornex-control/releases/tag/v1.1.0

Шаг 1. Проверить предпосылки: php -v (нужен 8.5), php -m (curl, json, mbstring, openssl, pdo_sqlite).
Шаг 2. Развернуть дерево и конфигурацию уровнем выше приложения:
git clone https://github.com/paulmann/fornex-control.git /srv/fornex-control
install -m 640 /srv/fornex-control/.env.example /srv/fornex.env
php -r "echo password_hash('пароль', PASSWORD_DEFAULT), PHP_EOL;"
Шаг 3. Заполнить FORNEX_API_KEY и ADMIN_PASSWORD_HASH, создать var, var/log, var/snapshots с правами на запись.
Шаг 4. Проверить чтение: php backend/bin/fornex domains; zone:show <домен>
Шаг 5. Снять снимок до любых изменений: zone:snapshot <домен>
Шаг 6. Прочитать план шага 1: preset:apply <домен> google-workspace --param:dkim='...'
Шаг 7. Применить шаг 1 с --apply только после прочтения плана; дождаться подтверждения домена у провайдера.
Шаг 8. Повторить для шагов 2-4, каждый раз читая план; шаг 2 и 4 не требуют ввода имени домена.
Шаг 9. Проверить результат: verify <домен> — NS, MX, SPF, DKIM, DMARC, PTR по ns1.fornex.com.
Шаг 10. Проверить журнал: audit --limit=10 — записи с идентификаторами запросов и именами снимков.

Запрещено:
- применять изменения без предпросмотра плана и без снимка зоны;
- класть .env с ключом API в веб-корень;
- удалять записи масками: удаление только по id;
- править SOA и NS через панель;
- считать ответ публичного резолвера истиной сразу после записи: проверять через ns1.fornex.com;
- выдавать токен с правом записи, если достаточно чтения;
- печатать значение ключа API или хеш пароля в отчёте.

Что считать успехом: domains и zone:show отвечают, снимок создан, четыре шага применены по одному, verify показывает корректные MX/SPF/DKIM/DMARC, в журнале есть записи со ссылками на снимки.