# 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//` и **доказать**, что копия создана. Отказ здесь останавливает операцию до первой мутации. 2. Подготовить candidate-файлы и проверить их `nft -c -f`. 3. Подставить candidate в production-пути. 4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-` с окном 45 секунд. ```bash systemd-run \ --unit=hy2xs-fw-rollback-.service \ --on-active=45s \ --timer-property=RemainAfterElapse=no \ --timer-property=AccuracySec=1s \ /bin/sh /run/hy2xs/rollback//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//auto-rollback-fired ``` Маркер создаётся rollback-скриптом **первым действием**, до любой проверки и до первой попытки восстановления. Отсюда инвариант фиксации успеха: ```text маркер auto-rollback-fired отсутствует И hy2xs-fw-rollback-.timer в состоянии inactive И hy2xs-fw-rollback-.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 оба нужных сервиса стартуют корректно