Files
HY2XS_flamy/docs/runtime/07-systemd-and-firewall.md
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

309 lines
19 KiB
Markdown
Raw Permalink 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`
Окружение `hysteria-server.service` фиксировано тремя переменными:
| переменная | значение | зачем |
| --- | --- | --- |
| `HYSTERIA_LOG_LEVEL` | `info` | уровень журнала |
| `HYSTERIA_LOG_FORMAT` | `json` | структурный журнал; админка разбирает именно его формат — `time` числом epoch millis |
| `HYSTERIA_DISABLE_UPDATE_CHECK` | `1` | версией Hysteria владеет `versions.env` -> сборка -> пакет -> оркестратор |
Проверка обновлений выключена не из соображений безопасности — Hysteria сама
бинарник не заменяет, — а потому что у версии обязан быть один владелец. Иначе
служба ходит наружу при каждом старте и печатает в журнал советы, которые к этой
установке не относятся. В тестовом окружении продукта она и так отключена, и
production не имеет права отличаться.
Важно:
- 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 оба нужных сервиса стартуют корректно