Files
HY2XS_flamy/docs/runtime/07-systemd-and-firewall.md
T
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

295 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# systemd and firewall
## Цель документа
Зафиксировать базовый systemd/firewall слой под новую install model.
## systemd: Hysteria2
Базовые требования:
- отдельный unit `hysteria-server.service`
- отдельный пользователь `hysteria`
- автозапуск после reboot
- restart policy для падений
Базовый ExecStart:
```bash
/usr/local/bin/hysteria server -c /etc/hysteria/config.yaml
```
## systemd: HY2XS admin
Базовые требования:
- отдельный unit `hy2xs-admin.service`
- запуск от `User=hy2xs-admin`, не от root
- отдельный install dir
- отдельный data dir
- отдельный жизненный цикл от Hysteria
Рекомендуемый hardening:
- `NoNewPrivileges=true`
- `PrivateTmp=true`
- `UMask=0077`
- `ProtectHome=true`
- `ProtectSystem=strict`
- `ReadOnlyPaths=/etc/hysteria/config.yaml`
- `ReadWritePaths=/var/lib/hy2xs-admin /var/log/hy2xs`
- `RestrictAddressFamilies=AF_INET AF_UNIX`
- `SystemCallArchitectures=native`
- `LockPersonality=true`
Для `hysteria-server.service` также обязателен sandbox-контур:
- `ProtectSystem=strict`
- `ReadOnlyPaths=/etc/hysteria/config.yaml`
- `ReadWritePaths=/var/lib/hysteria`
- `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`
Важно:
- HY2XS admin не должен запускаться как часть unit Hysteria
- unit-файлы не должны быть склеены
## Базовая firewall-модель
Нужно разрешить:
- UDP-порт Hysteria2
- TCP-порт SSH
- established/related traffic
IPv4-only policy:
- использовать `table ip`, а не `table inet`;
- IPv6 правила не добавлять;
- UI работает только на `127.0.0.1` в production baseline.
## Firewall modes
`HY2XS_FIREWALL_MODE=managed`:
- orchestrator управляет baseline nftables.
- существующий `foreign` entrypoint блокирует install/reconfigure (fail-fast).
`HY2XS_FIREWALL_MODE=takeover`:
- явный destructive takeover.
- использовать только после ручной проверки хоста.
`HY2XS_FIREWALL_MODE=external`:
- orchestrator не модифицирует nftables.
- оператор полностью управляет firewall вручную.
`HY2XS_FIREWALL_MODE=off`:
- firewall-слой оркестратора отключён.
- `--skip-firewall` эквивалентно runtime-отключению на время операции.
После staged-проверки можно включать default policy `drop`.
## Порядок применения
1. Снять резервную копию `/etc/nftables.conf`, `/etc/nftables.d/hy2xs.nft` и
состояния юнита `nftables.service` в `/run/hy2xs/rollback/<op-id>/` и
**доказать**, что копия создана. Отказ здесь останавливает операцию до
первой мутации.
2. Подготовить candidate-файлы и проверить их `nft -c -f`.
3. Подставить candidate в production-пути.
4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-<op-id>`
с окном 45 секунд.
```bash
systemd-run \
--unit=hy2xs-fw-rollback-<op-id>.service \
--on-active=45s \
--timer-property=RemainAfterElapse=no \
--timer-property=AccuracySec=1s \
/bin/sh /run/hy2xs/rollback/<op-id>/auto-rollback.sh
```
5. Применить ruleset и проверить SSH/Hysteria/UI.
6. Снять guard и **доказать**, что он снят (см. ниже).
7. Долговечно зафиксировать успех.
8. Только после этого удалить данные отката и candidate-файлы.
Порядок шагов 6–8 существенен: между снятием guard и удалением данных отката
стоит фиксация успеха, поэтому отказ записи маркера (заполненный диск,
read-only ФС) оставляет откат выполнимым.
### Rollback guard
Guard — это защита от потери доступа к серверу. Он существует ради ситуации, в
которой применённые правила отрезали SSH и оператор больше не может ничего
сделать руками.
Окно guard намеренно короткое — 45 секунд — и намеренно **не** покрывает
smoke: smoke на медленном, но исправном сервере может идти заметно дольше.
Увеличение окна лечило бы гонку расширением, а не устранением.
#### Почему у таймера заданы `AccuracySec` и `RemainAfterElapse`
`OnActiveSec=45s` сам по себе **не** означает «ровно через 45 секунд».
`systemd.timer` разрешает себе сработать в окне
```text
цель ... цель + AccuracySec
```
объединяя пробуждения ради экономии энергии, и умолчание `AccuracySec=` —
`1min`. То есть без явного значения guard, про который эта страница и текст
отказа говорят «45 секунд», по контракту systemd мог сработать и через 105.
Поэтому точность задаётся явно: `AccuracySec=1s`, и реальное окно — **45–46
секунд**. Коалесценция пробуждений аварийному guard'у не нужна: он взводится
один раз за операцию и почти всегда снимается, не сработав.
`RemainAfterElapse=no` задаётся по другой причине. Отработавший одноразовый
таймер обязан выгрузиться — на этом стоит право барьера покоя считать
отсутствие юнита доказательством того, что откатывать firewall больше некому.
`systemd-run` выставляет это свойство транзиентным таймерам сам, но инвариант,
который держится на чужом умолчании, нигде не записан и ничем не проверяется;
в явном виде он попадает и в journal, и в тест.
Вместо этого guard оставляет за собой факт:
```text
/run/hy2xs/rollback/<op-id>/auto-rollback-fired
```
Маркер создаётся rollback-скриптом **первым действием**, до любой проверки и до
первой попытки восстановления. Отсюда инвариант фиксации успеха:
```text
маркер auto-rollback-fired отсутствует
И hy2xs-fw-rollback-<op-id>.timer в состоянии inactive
И hy2xs-fw-rollback-<op-id>.service в состоянии inactive
=> автоматический откат больше не может сработать
```
Пока этот инвариант не доказан, `phase: installed` не записывается. Если guard
успел сработать, операция **обязана** завершиться отказом — даже если smoke
прошёл зелёным: сервер в этот момент работает на прежнем firewall, а не на том,
который сгенерировала операция.
Проверка состояния юнитов идёт по `ActiveState`, а не по коду возврата
`systemctl stop`: для транзиентного юнита, который уже отработал и был убран
systemd, `stop` возвращает 5, и этот исход неотличим от успешного снятия
взведённого таймера.
У факта срабатывания два независимых канала, и это не избыточность. Маркер —
обычный. Отказ юнита — аварийный: если записать маркер не удалось (заполненный
tmpfs `/run`, read-only ФС), скрипт поднимает код возврата, юнит уходит в
`failed`, а `failed` на пути фиксации успеха запрещён так же, как и маркер.
Без второго канала инвариант был бы верен лишь при дополнительном условии
«guard способен записать маркер», которого никто не гарантирует.
Сам rollback-скрипт восстанавливает файлы и ruleset, накапливает код возврата и
уходит в `failed` при частичном восстановлении. Состояние `nftables.service` он
сознательно не трогает: у этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то
есть остановка сервиса стёрла бы только что восстановленные правила. Enable и
active восстанавливает обычный откат в процессе оркестратора, где порядок стадий
контролируется.
### Guard переживает свой процесс
Guard — объект systemd, а не часть процесса оркестратора. Аварийно умершая
операция оставляет его вооружённым, и он способен вернуть прежний firewall уже
посреди **следующей** операции. Замок операций от этого не защищает: он
действует, пока жив процесс-держатель.
Поэтому условие начала новой операции — не «PID предыдущей мёртв», а «у
предыдущей не осталось исполнителей, способных изменить систему». Каждый захват
замка проходит через барьер покоя.
#### Покой перечисляется белым списком
Покой — это `inactive` и `failed`, и **только** они. Отработавший guard больше
ничего не сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот
инструмент, которым чинят последствия. Всё остальное считается непокоем и
запрещает операцию.
Список именно белый, а не чёрный. Перечисление непокойных состояний
(`active`, `activating`, `deactivating`, `reloading`) объявляло бы безопасным
любое состояние, которого автор не назвал, — включая те, которых он не знал:
systemd 257 знает ещё `maintenance` и `refreshing`, и список может пополниться
снова. Незнакомое состояние systemd обязано блокировать операцию, а не
проходить молча.
Единственное исключение — `*.timer` в `SubState=elapsed` или `dead`. `ActiveState`
таймера отвечает на вопрос «юнит загружен и в строю», а не «он ещё может
сработать»: в systemd `TIMER_ELAPSED` отображается в `UNIT_ACTIVE` так же, как
`TIMER_WAITING`, и различает их только `SubState`. У наших guard'ов такого не
бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не должен
зависеть от того, чем именно создан таймер: иначе отработавший таймер запрещал
бы install/reconfigure/repair/doctor навсегда — и запрещал бы ради отката,
который уже произошёл. Триггернутый сервис при этом виден барьеру отдельным
юнитом и остаётся непокоем, пока выполняется.
#### Отказ запроса к systemd — это отказ операции
Если `systemctl` не ответил, барьер **не** считает систему спокойной:
```text
unable to verify firewall rollback guard state;
systemd query failed, refusing to start a lifecycle operation
```
Отсутствие ответа — отсутствие наблюдения, а не наблюдение покоя. Обратная
трактовка давала реальный сценарий потери firewall:
```text
systemd жив, старый rollback timer взведён
-> запрос к systemctl/D-Bus временно отказывает
-> список guard'ов пуст
-> барьер считает систему спокойной
-> новая операция начинает менять firewall
-> старый таймер срабатывает поверх неё
```
Механизм, обязанный **доказать** отсутствие асинхронного исполнителя, принимал
невозможность получить доказательство за положительный результат. Это прямо
противоположно политике замка операций, где сомнение трактуется в пользу
отказа.
Практического выигрыша у прежнего поведения не было: `systemd-run` требуется в
preflight, поэтому без работающего systemd операция всё равно откажет — просто
позже и с менее внятной диагностикой.
Состояния `hysteria-server`, `hy2xs-admin` и `nftables.service` барьер
сознательно не проверяет: незавершённый `systemctl restart` ничего не
откатывает, он лишь повторяет то, что новая операция сделает сама.
#### Один наблюдатель на барьер и на отчёт
Состояние guard'ов читает одна функция, и `hy2xs-orchestrator status` берёт его
у неё же. Раньше у status была своя копия листинга, и она расходилась с
барьером по трём пунктам сразу: без `--plain` (у `failed`-юнита первой колонкой
идёт маркер ``), с `|| true` (отказ systemd превращался в «guard'ов нет») и
без разбора состояний — вооружённым считался любой найденный юнит. На практике
это означало, что аварийно сработавший guard оставлял `failed`-сервис
загруженным до `reset-failed`, status вечно показывал `firewall_state:
guard_active`, а барьер тот же самый юнит считал покоем и разрешал `repair`.
`status` при этом остаётся отчётом: он не берёт замок и существует в том числе
для сломанного хоста, поэтому «спросить не удалось» попадает в JSON значением
`rollback_guard_state: "unknown"`, а не отказом команды.
### Проверка эффективного firewall
`nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и
поэтому ничего не говорит о том, чей это firewall. Smoke дополнительно сверяет:
1. `/etc/nftables.d/hy2xs.nft` совпадает с фрагментом, отрендеренным для этой
конфигурации;
2. `/etc/nftables.conf` принадлежит HY2XS и подключает именно его;
3. таблица `inet hy2xs` реально загружена в ядро.
Все три — наблюдение, поэтому проверка выполняется и в `doctor`, где она
обнаруживает расхождение effective firewall с конфигурацией.
## Что не делаем
В baseline не делаем:
- port hopping
- сложную динамическую firewall-логику
- смешение UI-портов и публичного транспортного порта в один firewall-контур без правил
## Инварианты
Система считается корректной, если:
1. Hysteria и HY2XS admin работают отдельными systemd unit
2. Hysteria слушает нужный UDP-порт
3. SSH не ломается после применения firewall
4. firewall-политика не противоречит listen policy
5. после reboot оба нужных сервиса стартуют корректно