Files
HY2XS_flamy/docs/07-systemd-and-firewall.md
T
founder 76d78ac71f fix(orchestrator): закрыть два остатка на стыке guard и замка операций
Оба дефекта — в механизмах, введённых предыдущими коммитами, и оба относятся к
гарантиям, ради которых эти механизмы вводились.

1. Отказ записи `auto-rollback-fired` оставался незамеченным.

Инвариант фиксации "маркера нет и юниты inactive => guard не сработал" верен
только при дополнительном условии "guard способен записать маркер". Пока `rc=0`
стояло ПОСЛЕ создания маркера, отказ записи (заполненный tmpfs /run, read-only
ФС) не влиял ни на что: скрипт успешно восстанавливал прежний firewall,
завершался кодом 0, юнит уходил в inactive, маркера не было — и операция
фиксировала успех после реально сработавшего отката.

`rc` объявляется до первой операции, включая создание маркера, а ранний выход
возвращает его вместо жёсткого `exit 0`. У факта срабатывания появилось два
независимых канала: маркер и отказ юнита, потому что на пути фиксации успеха
допустим ровно один ActiveState — inactive.

Заодно маркер создаётся `touch`, а не `: >file`: двоеточие — special builtin
POSIX, ошибка перенаправления на нём обязана завершить неинтерактивный shell
целиком, и в dash скрипт умер бы ДО восстановления firewall.

2. Новая операция могла начаться, пока guard предыдущей ещё вооружён.

Замок действует, пока жив процесс-держатель. Guard — отдельный объект systemd,
переживающий свой процесс:

    A берёт замок -> применяет firewall -> вооружает guard на 45s
    A аварийно умирает
    B берёт замок и начинает менять production paths
    guard A срабатывает и возвращает firewall, который был ДО A

Случай SIGTERM/SIGHUP хуже, чем kill -9: обработчик снимает замок сам, поэтому
проверка живости держателя не видит вообще ничего, а таймер остаётся.

Введён барьер покоя `assertNoPendingRollbackGuard`, через который проходит
каждый захват замка — дважды, до и после, потому что между ними умирающая
операция успевает вооружить guard, — и PHASE 0 установщика. Непокоем считаются
active/activating/deactivating/reloading; `failed` и `inactive` — покой, иначе
барьер блокировал бы `repair`, которым чинят последствия.

Плюс P1: восстановление UnitFileState у nftables.service больше не обещает
точности, которой не даёт. `enable --runtime` не удаляет постоянную ссылку,
поэтому "восстановление" enabled-runtime оставляло юнит включённым в обоих
scope. Восстанавливаются enabled/disabled — то, что операция реально меняет, —
остальные состояния называются оператору и не трогаются.

Тесты: поведенческая проверка раннего пути rollback-скрипта настоящим shell
(ветка заканчивается до первой команды восстановления и безопасна для запуска),
проверка двойного вызова барьера и снятия замка при его отказе, структурные
инварианты. Приёмка и docs (D1h, уточнение D1f) — там же.
2026-08-31 03:30:14 +05:00

201 lines
11 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 секунд.
5. Применить ruleset и проверить SSH/Hysteria/UI.
6. Снять guard и **доказать**, что он снят (см. ниже).
7. Долговечно зафиксировать успех.
8. Только после этого удалить данные отката и candidate-файлы.
Порядок шагов 6–8 существенен: между снятием guard и удалением данных отката
стоит фиксация успеха, поэтому отказ записи маркера (заполненный диск,
read-only ФС) оставляет откат выполнимым.
### Rollback guard
Guard — это защита от потери доступа к серверу. Он существует ради ситуации, в
которой применённые правила отрезали SSH и оператор больше не может ничего
сделать руками.
Окно guard намеренно короткое — 45 секунд — и намеренно **не** покрывает
smoke: smoke на медленном, но исправном сервере может идти заметно дольше.
Увеличение окна лечило бы гонку расширением, а не устранением.
Вместо этого 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 предыдущей мёртв», а «у
предыдущей не осталось исполнителей, способных изменить систему». Каждый захват
замка проходит через барьер покоя: если хоть один `hy2xs-fw-rollback-*` находится
в состоянии `active`, `activating`, `deactivating` или `reloading`, операция
отказывает.
`inactive` и `failed` считаются покоем: отработавший guard больше ничего не
сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот инструмент,
которым чинят последствия.
Состояния `hysteria-server`, `hy2xs-admin` и `nftables.service` барьер
сознательно не проверяет: незавершённый `systemctl restart` ничего не
откатывает, он лишь повторяет то, что новая операция сделает сама.
### Проверка эффективного 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 оба нужных сервиса стартуют корректно