docs: описать транзакционный guard и взаимное исключение операций
- docs/07: полный порядок staged apply, инвариант снятия guard, объяснение почему окно 45 секунд не обязано покрывать smoke и почему guard не трогает nftables.service, семантическая проверка эффективного firewall; - docs/11: разделы A5e/A5f для новых unit-тестов и серверные сценарии D1e (guard доходит до дедлайна), D1f (конкурентные операции), D1g (успешная операция не оставляет следов транзакции); матрица и acceptance criteria дополнены; - docs/12: разбор отказов "уже выполняется другая операция" и firewall_guard_fired; - docs/13: строки журнала guard в таблице recovery, новый раздел 8a про замок операций; - docs/14 и purge-v0.sh: очистка /run/hy2xs, замка операций и candidate-файлов firewall — /run это tmpfs, но очистка не имеет права требовать перезагрузки; - README: защита от потери доступа при смене firewall и раздел "Одна операция за раз"; - CHANGELOG: шестой проход.
This commit is contained in:
@@ -32,6 +32,97 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
запускался, но отдельные его шаги могли молча не выполнить восстановление,
|
||||
отчитаться успехом и уничтожить резервную копию.
|
||||
|
||||
Шестой проход — управление самой транзакцией, а не копированием файлов.
|
||||
Предыдущие проходы сделали надёжными шаги операции; здесь закрываются два
|
||||
допущения, на которых держалась операция целиком: что снятие защиты от отката
|
||||
действительно произошло и что операция на сервере ровно одна.
|
||||
|
||||
### Исправлено — границы транзакции
|
||||
|
||||
- **Снятие rollback guard было утверждением, а не фактом.** Порядок фиксации
|
||||
успеха выглядел так:
|
||||
|
||||
```text
|
||||
systemctl stop <unit>.timer <unit>.service || true
|
||||
-> "firewall rollback timer disarmed"
|
||||
-> phase=installed
|
||||
```
|
||||
|
||||
Между «мы думаем, что guard снят» и «guard действительно снят» не было ни
|
||||
одной проверки: `|| true` стирал код возврата, и взведённый таймер мог
|
||||
вернуть прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать
|
||||
`|| true` было нельзя — для транзиентного юнита, уже убранного systemd,
|
||||
`systemctl stop` возвращает 5, и этот исход неотличим от успеха.
|
||||
|
||||
Введён маркер `/run/hy2xs/rollback/<op-id>/auto-rollback-fired`, который
|
||||
rollback-скрипт создаёт первым действием. Снятие guard стало доказательством:
|
||||
маркер отсутствует, `ActiveState` обоих юнитов равен `inactive`, и только
|
||||
после этого записывается `phase: installed`.
|
||||
|
||||
- **Автоматический откат мог сработать во время успешного smoke, и операция
|
||||
этого не замечала.** Окно guard — 45 секунд — заведомо короче худшего случая
|
||||
smoke, а единственной проверкой firewall в smoke был `nft -c`: разбор
|
||||
текущего файла, каким бы он ни был. Откатившийся прежний ruleset проходил её
|
||||
зелёным, и сервер объявлялся успешно настроенным с **предыдущим** firewall —
|
||||
особенно дорого при смене порта Hysteria, SSH или ACME.
|
||||
|
||||
Лечится не увеличением окна: сработавший guard теперь запрещает фиксацию
|
||||
успеха независимо от результата smoke и даёт собственную причину отказа
|
||||
`firewall_guard_fired`. Дополнительно smoke сверяет эффективный firewall с
|
||||
конфигурацией операции — фрагмент правил, принадлежность entrypoint и
|
||||
фактически загруженную таблицу `inet hy2xs`.
|
||||
|
||||
- **У оркестратора не было блокировки операций.** Ни `flock`, ни mutex, ни
|
||||
lockfile — при том что вся архитектура отката опиралась на невысказанное
|
||||
допущение об одной операции за раз. `install-state.json` замком не является:
|
||||
это запись о состоянии, а не право на изменение. Два одновременных
|
||||
`reconfigure` доходили до конца каждый по-своему, и уникальные `op-id` не
|
||||
спасали — они разделяют резервные копии, но production paths общие. Дальше
|
||||
любая из операций могла упасть и «восстановить» состояние поверх изменений
|
||||
другой, отчитавшись полным успехом.
|
||||
|
||||
Введён эксклюзивный замок `/run/lock/hy2xs-orchestrator.lock`.
|
||||
`install`/`reconfigure`/`repair`/`doctor` берут его и отказывают **до первой
|
||||
мутации**; `status`/`diagnostics` не берут, но сообщают об идущей операции;
|
||||
`preflight-install` отказывает до собственных проверок. Замок снимается при
|
||||
любом завершении держателя, включая обрыв SSH.
|
||||
|
||||
- **Автоматический откат маскировал собственные ошибки.** Внутри `systemd-run`
|
||||
оставались `cp ... || true` и `nft -f ... || true`, поэтому при частичном
|
||||
восстановлении юнит завершался кодом 0 — ровно в сценарии, где guard является
|
||||
последней линией защиты от потери SSH. Скрипт переписан: независимые стадии,
|
||||
накопление кода возврата, `failed` с диагностикой в journal.
|
||||
|
||||
- **Откат не восстанавливал состояние `nftables.service`.** `applyFirewall`
|
||||
выполняет `systemctl enable --now nftables`, но копия хранила только файлы
|
||||
правил. После отката неудачной первой установки сервис оставался включённым в
|
||||
автозапуск, хотя до неё был выключен. Состояние снимается вместе с файлами и
|
||||
восстанавливается стадиями, идущими до применения ruleset: у
|
||||
`nftables.service` `ExecStop=nft flush ruleset`, и обратный порядок стёр бы
|
||||
восстановленные правила.
|
||||
|
||||
- **`/etc/nftables.conf.candidate` не удалялся никогда.** Успешная установка
|
||||
оставляла его на сервере навсегда. Candidate-файлы убираются после успеха и
|
||||
best-effort при откате; `purge-v0.sh` тоже их знает.
|
||||
|
||||
- **Скрипт автоотката собирался однострочником внутри `sh -c '...'`.**
|
||||
Интерполяции проходили через shell-квотирование и подставлялись внутрь уже
|
||||
закавыченной строки: корректность держалась на склейке соседних кавычек и на
|
||||
том, что op-id не содержит пробелов. Скрипт вынесен в отдельную чистую
|
||||
функцию, ключ операции проверяется, а результат покрыт тестом и разбирается
|
||||
настоящим shell-парсером.
|
||||
|
||||
- **Ключ операции считался в двух местах и разошёлся.** `install` писал в
|
||||
маркер сырой ISO-timestamp с двоеточиями, тогда как каталог отката назывался
|
||||
санитизированным ключом: путь `/run/hy2xs/rollback/<op_id>`, который runbook
|
||||
предлагает открыть, на сервере не существовал.
|
||||
|
||||
- **Стадии восстановления `reconfigure` были независимы по группе, а не по
|
||||
файлу.** Отказ `cp` для `hy2xs-admin.service` отменял восстановление
|
||||
`hysteria-server.service`: внешняя стадия честно попадала в список
|
||||
отказавших, но принцип «восстановить максимум» на уровне файлов не
|
||||
выполнялся.
|
||||
|
||||
### Исправлено — целостность отката
|
||||
|
||||
- **Данные для отката уничтожались до фиксации успеха.** Успешный install
|
||||
|
||||
@@ -671,6 +671,28 @@ hy2xs-orchestrator status \
|
||||
|
||||
При `managed` и `takeover` генерируется nftables‑конфигурация с default drop policy, разрешением loopback, established/related, SSH‑порта, ACME challenge‑порта, Hysteria2 UDP‑порта и ICMP echo‑request.
|
||||
|
||||
### Защита от потери доступа при смене firewall
|
||||
|
||||
При `HY2XS_FIREWALL_STAGED_APPLY=true` (значение по умолчанию) перед применением новых правил HY2XS взводит rollback guard — транзиентный systemd‑юнит с окном 45 секунд. Если операция не снимет его вовремя, guard вернёт прежний firewall, и SSH останется доступным.
|
||||
|
||||
Окно намеренно короткое и **не** обязано покрывать smoke‑checks: на медленном сервере они идут дольше. Вместо этого guard оставляет за собой факт срабатывания в `/run/hy2xs/rollback/<op-id>/auto-rollback-fired`, и операция не имеет права объявить себя успешной, если этот файл появился, — сервер в такой момент работает на прежнем firewall, а не на том, который она сгенерировала. Установка завершится отказом с `phase: firewall_guard_fired`, и её нужно повторить после устранения причины медленного прохода.
|
||||
|
||||
Дополнительно smoke сверяет, что действующий firewall — именно тот, который сгенерирован для текущей конфигурации: разбора `/etc/nftables.conf` для этого недостаточно, потому что прежний ruleset тоже валиден.
|
||||
|
||||
## Одна операция за раз
|
||||
|
||||
`install`, `reconfigure`, `repair` и `doctor` сериализованы эксклюзивным замком `/run/lock/hy2xs-orchestrator.lock`. Вторая операция отказывает сразу и **до первой мутации**:
|
||||
|
||||
```text
|
||||
another HY2XS operation is already in progress: reconfigure (pid 4242, started at …)
|
||||
```
|
||||
|
||||
Это не перестраховка: конфиги, unit‑файлы, `/etc/nftables.conf` и маркер установки — общие, и две одновременные операции записывают их поверх друг друга, после чего откат одной «восстанавливает» состояние поверх изменений другой.
|
||||
|
||||
`status` и `diagnostics collect` замок не берут — они нужны в том числе во время долгой операции, — но сообщают о ней в своём выводе.
|
||||
|
||||
Замок снимается сам при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH. Если процесс был убит `kill -9`, следующая операция обнаружит мёртвого держателя и переиспользует замок самостоятельно.
|
||||
|
||||
## Реконфигурация
|
||||
|
||||
После изменения `/etc/hy2xs/hy2xs.env` сначала выполните dry‑run:
|
||||
|
||||
@@ -82,12 +82,78 @@ IPv4-only policy:
|
||||
|
||||
## Порядок применения
|
||||
|
||||
1. Подготовить candidate-файл (`/etc/nftables.d/hy2xs.nft`).
|
||||
2. Проверить `nft -c -f`.
|
||||
3. Создать rollback timer.
|
||||
4. Применить candidate и проверить SSH/Hysteria/UI.
|
||||
5. При успехе отменить rollback timer.
|
||||
6. При провале — rollback.
|
||||
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, и этот исход неотличим от успешного снятия
|
||||
взведённого таймера.
|
||||
|
||||
Сам rollback-скрипт восстанавливает файлы и ruleset, накапливает код возврата и
|
||||
уходит в `failed` при частичном восстановлении. Состояние `nftables.service` он
|
||||
сознательно не трогает: у этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то
|
||||
есть остановка сервиса стёрла бы только что восстановленные правила. Enable и
|
||||
active восстанавливает обычный откат в процессе оркестратора, где порядок стадий
|
||||
контролируется.
|
||||
|
||||
### Проверка эффективного 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 с конфигурацией.
|
||||
|
||||
## Что не делаем
|
||||
|
||||
|
||||
@@ -245,6 +245,59 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
||||
- успешный smoke фиксируется отдельной фазой до снятия таймера;
|
||||
- уборка после точки фиксации выполняется best-effort.
|
||||
|
||||
## A5e. Транзакционность rollback guard (unit)
|
||||
|
||||
`orchestrator/test/firewall-guard.test.ts`:
|
||||
|
||||
- маркер `auto-rollback-fired` создаётся rollback-скриптом **первым действием** —
|
||||
до проверки `prepared` и до первой попытки восстановления, в том числе когда
|
||||
восстанавливать нечего. Без этого «guard сработал» недоказуемо: транзиентные
|
||||
юниты systemd после выполнения исчезают, и `systemctl stop` для них неотличим
|
||||
от успешного снятия взведённого таймера;
|
||||
- скрипт не маскирует ошибки (`|| true`, `2>/dev/null`), не использует `set -e`
|
||||
и возвращает накопленный `rc`: каждый сообщённый отказ поднимает код возврата,
|
||||
поэтому частичное восстановление уходит в `failed`, а не в молчаливый `0`;
|
||||
- скрипт не трогает `nftables.service`: у него `ExecStop=nft flush ruleset`, и
|
||||
остановка сервиса стёрла бы только что восстановленные правила;
|
||||
- скрипт разбирается **настоящим** shell-парсером. Парсер принимается только
|
||||
после двусторонней проверки — он обязан принять заведомо корректный скрипт и
|
||||
отвергнуть заведомо сломанный, иначе тест ничего не проверяет;
|
||||
- небезопасный ключ операции отвергается: он служит именем каталога, именем
|
||||
systemd-юнита и подставляется в текст скрипта;
|
||||
- снятие guard проверяет маркер **с обеих сторон** остановки, подтверждается
|
||||
`ActiveState` обоих юнитов, и на пути фиксации успеха допускает единственное
|
||||
состояние — `inactive`; на пути восстановления `failed` тоже допустим;
|
||||
- сработавший guard опознаётся по **типу** ошибки: ошибка с тем же текстом, но
|
||||
другого типа классифицируется по владению, как и прежде;
|
||||
- эффективный firewall сверяется семантически (фрагмент, entrypoint,
|
||||
загруженная таблица), и проверка выполняется read-only раннерами — та же
|
||||
проверка идёт в `doctor`;
|
||||
- состояние `nftables.service` снимается до первой мутации и восстанавливается
|
||||
стадиями, идущими **до** применения ruleset;
|
||||
- candidate-файлы убираются после успеха и best-effort при откате;
|
||||
- ключ операции считается одной функцией: install писал в маркер сырой
|
||||
ISO-timestamp, и путь `/run/hy2xs/rollback/<op_id>` из runbook не существовал.
|
||||
|
||||
## A5f. Взаимное исключение операций (unit)
|
||||
|
||||
`orchestrator/test/operation-lock.test.ts`:
|
||||
|
||||
- второй захват при живом держателе отказывает, и отказ называет держателя —
|
||||
команду, PID и время начала;
|
||||
- замок снимается в `finally` и после отказа операции: иначе первая же неудачная
|
||||
установка заблокировала бы сервер до перезагрузки;
|
||||
- замок мёртвого держателя переиспользуется, временный файл переиспользования не
|
||||
остаётся на диске;
|
||||
- непонятое содержимое замка **не** снимается автоматически: оно не доказывает
|
||||
отсутствие операции, и сомнение трактуется в пользу отказа;
|
||||
- `readLockHolder` отличает «замка нет» от «замок нечитаем»;
|
||||
- захват под read-only guard отказывает, наблюдение — разрешено. Замок берётся
|
||||
до включения guard, и проверка существует, чтобы перенос захвата внутрь
|
||||
читающей фазы отказал громко, а не записал файл молча;
|
||||
- политика CLI закреплена структурно: `install`/`reconfigure`/`repair`/`doctor`
|
||||
вызываются только под замком, `status`/`diagnostics` его не берут, но сообщают
|
||||
об идущей операции, а `preflight-install` отказывает до собственных проверок.
|
||||
|
||||
## A5b. Долговечная запись маркера (unit)
|
||||
|
||||
`orchestrator/test/atomic-write.test.ts`:
|
||||
@@ -956,6 +1009,111 @@ dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
|
||||
`manifest.json`, и в нём перечислены все семь путей, включая отсутствовавшие с
|
||||
`"present": false`.
|
||||
|
||||
## D1e. Guard доходит до дедлайна — фиксация успеха запрещена
|
||||
|
||||
Проверяется на чистом хосте. Это сценарий гонки между автоматическим откатом
|
||||
firewall и успешным smoke.
|
||||
|
||||
Окно guard — 45 секунд, и оно намеренно короче худшего случая smoke: на
|
||||
медленном, но исправном сервере retry-бюджеты дают заметно больше. Раньше это
|
||||
означало, что автоматический откат мог вернуть прежний firewall, пока smoke
|
||||
продолжает идти, а единственной проверкой firewall в smoke был `nft -c` — разбор
|
||||
текущего файла, каким бы он ни был. Прежний валидный ruleset проходил её
|
||||
зелёным, и сервер объявлялся успешно настроенным с **предыдущим** firewall.
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. установка доходит до шага `firewall`, в журнале появляется
|
||||
`firewall rollback guard armed`;
|
||||
2. smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один
|
||||
из сервисов — например, добавить в `hy2xs-admin.service` временный
|
||||
`ExecStartPre=/bin/sleep 60` и выполнить `systemctl daemon-reload` до запуска
|
||||
установки;
|
||||
3. guard срабатывает: в journal появляется юнит
|
||||
`hy2xs-fw-rollback-<op-id>.service`, а на диске —
|
||||
`/run/hy2xs/rollback/<op-id>/auto-rollback-fired`;
|
||||
4. установка **обязана** завершиться отказом, даже если smoke успел сойтись;
|
||||
5. в маркере установки стоит `phase: firewall_guard_fired`, а не
|
||||
`installed`, и не `smoke_failed`;
|
||||
6. `installed: true` не записан;
|
||||
7. выполняется обычный откат операции: firewall возвращается к прежнему
|
||||
состоянию, развёрнутые этой операцией юниты останавливаются;
|
||||
8. SSH остаётся доступным.
|
||||
|
||||
Отдельно проверяется вторая половина того же дефекта — семантический smoke.
|
||||
Если на рабочей установке подменить `/etc/nftables.d/hy2xs.nft` на прежний
|
||||
валидный ruleset и выполнить `hy2xs-orchestrator doctor`, диагностика обязана
|
||||
отказать с сообщением про несовпадение эффективного firewall, а не пройти по
|
||||
`nft -c`.
|
||||
|
||||
## D1f. Конкурентная операция отказывает до первой мутации
|
||||
|
||||
Проверяется на рабочей установке. Проверяемое свойство — отказ происходит
|
||||
**до** снятия резервной копии и до первой мутации, а не в середине транзакции.
|
||||
|
||||
1. запускается длинный `reconfigure --apply` (например, с задержкой в
|
||||
`ExecStartPre`, как в D1e);
|
||||
2. во втором терминале, пока первый идёт, запускается второй
|
||||
`reconfigure --apply`;
|
||||
3. второй отказывает сразу, с текстом
|
||||
`another HY2XS operation is already in progress: reconfigure (pid …)`;
|
||||
4. `/etc/hy2xs/backups/` **не** пополнился каталогом второй операции;
|
||||
5. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` изменены
|
||||
ровно один раз — первой операцией;
|
||||
6. `/run/hy2xs/rollback/` содержит каталог только первой операции.
|
||||
|
||||
Те же проверки для пар:
|
||||
|
||||
```text
|
||||
install идёт -> doctor отказывает
|
||||
install идёт -> install.sh отказывает на PHASE 0, до собственных проверок
|
||||
reconfigure идёт -> repair отказывает
|
||||
```
|
||||
|
||||
И обратная проверка — наблюдающие команды не блокируются:
|
||||
|
||||
```text
|
||||
reconfigure идёт -> hy2xs-orchestrator status
|
||||
→ выполняется
|
||||
→ в отчёте operation_in_progress = "reconfigure (pid …)"
|
||||
→ human_status предупреждает, что это снимок незавершённой транзакции
|
||||
|
||||
reconfigure идёт -> diagnostics collect
|
||||
→ выполняется
|
||||
→ в stderr есть note об идущей операции
|
||||
```
|
||||
|
||||
Отдельно проверяется, что замок не переживает своего держателя:
|
||||
|
||||
1. `reconfigure --apply` прерывается `Ctrl+C` — замок снят, следующий
|
||||
`reconfigure` проходит;
|
||||
2. процесс убивается `kill -9`, после чего следующая операция сообщает
|
||||
`is held by … which is no longer running; reclaiming it` и продолжает;
|
||||
3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции.
|
||||
|
||||
## D1g. Успешная установка не оставляет следов транзакции
|
||||
|
||||
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
|
||||
к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие
|
||||
её завершение.
|
||||
|
||||
После `installed`:
|
||||
|
||||
```text
|
||||
systemctl list-units --all 'hy2xs-fw-rollback-*' → пусто
|
||||
ls /run/hy2xs/rollback/ → пусто
|
||||
ls /run/lock/hy2xs-orchestrator.lock → отсутствует
|
||||
ls /etc/nftables.conf.candidate → отсутствует
|
||||
ls /etc/nftables.d/hy2xs.nft.candidate → отсутствует
|
||||
```
|
||||
|
||||
и `/var/lib/hy2xs/install-state.json` содержит `phase: installed`,
|
||||
`installed: true`, а `op_id` в нём совпадает с именем каталога, который лежал в
|
||||
`/run/hy2xs/rollback/` во время установки.
|
||||
|
||||
`/etc/nftables.conf.candidate` — прямая регрессия: он не удалялся вообще, и
|
||||
успешная установка оставляла его на сервере навсегда.
|
||||
|
||||
## D1a. Проход установки не спотыкается о собственный маркер
|
||||
|
||||
Проверяется на чистом хосте, обычной успешной установкой.
|
||||
@@ -1031,8 +1189,20 @@ hy2xs-orchestrator doctor
|
||||
- при `HY2XS_FIREWALL_MODE=managed` install/reconfigure блокируются;
|
||||
- при `HY2XS_FIREWALL_MODE=takeover` создаются backup/rollback guard и apply проходит.
|
||||
|
||||
4. **Rollback guard cleanup**:
|
||||
- после успешного apply/smoke не остаются `hy2xs-fw-rollback-*.timer/.service`.
|
||||
4. **Rollback guard cleanup** (сценарий D1g):
|
||||
- после успешного apply/smoke не остаются `hy2xs-fw-rollback-*.timer/.service`;
|
||||
- `/run/hy2xs/rollback/`, `/run/lock/hy2xs-orchestrator.lock` и
|
||||
`*.candidate` не переживают успешную операцию.
|
||||
|
||||
4a. **Guard доходит до дедлайна** (сценарий D1e):
|
||||
- `auto-rollback-fired` создан, операция завершается отказом с
|
||||
`phase: firewall_guard_fired`;
|
||||
- `installed: true` не записан, даже если smoke успел сойтись.
|
||||
|
||||
4b. **Конкурентные операции** (сценарий D1f):
|
||||
- вторая операция отказывает **до** снятия резервной копии и первой мутации;
|
||||
- `status`/`diagnostics` не блокируются и сообщают об идущей операции;
|
||||
- замок не переживает своего держателя.
|
||||
|
||||
5. **Partial install + repair**:
|
||||
- состояние `install-state` фиксирует промежуточную фазу;
|
||||
@@ -1118,3 +1288,10 @@ hy2xs-orchestrator doctor
|
||||
58. копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим
|
||||
59. ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха
|
||||
60. `doctor` не выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимом `install`
|
||||
61. снятие rollback guard доказывается, а не объявляется: отсутствие маркера `auto-rollback-fired` и `ActiveState=inactive` обоих юнитов — предусловие записи `phase: installed`
|
||||
62. сработавший guard запрещает фиксацию успеха, каким бы ни был результат smoke, и получает собственную причину отказа `firewall_guard_fired`
|
||||
63. smoke сверяет **эффективный** firewall с конфигурацией операции, а не только разбирает `/etc/nftables.conf`
|
||||
64. автоматический откат firewall сообщает о частичном восстановлении отказом юнита, а не молчаливым кодом 0, и сохраняет данные восстановления
|
||||
65. откат восстанавливает `enabled`/`active` состояние `nftables.service`, а не только файлы правил
|
||||
66. операции жизненного цикла сериализованы эксклюзивным замком: вторая операция отказывает до первой мутации, а `status`/`diagnostics` не блокируются
|
||||
67. замок снимается при любом завершении держателя, включая `Ctrl+C`, SIGTERM и обрыв SSH; замок мёртвого держателя переиспользуется безопасно
|
||||
|
||||
@@ -156,6 +156,72 @@ hy2xs-orchestrator repair \
|
||||
Флаг обязателен и осознан: без него `repair` работает только поверх полностью
|
||||
успешной установки.
|
||||
|
||||
### Операция отказывает: уже выполняется другая
|
||||
|
||||
```text
|
||||
another HY2XS operation is already in progress: reconfigure (pid 4242, started at …)
|
||||
```
|
||||
|
||||
`install`, `reconfigure`, `repair` и `doctor` сериализованы замком
|
||||
`/run/lock/hy2xs-orchestrator.lock`. Это не перестраховка: production paths —
|
||||
`/etc/hysteria/config.yaml`, unit-файлы, `/etc/nftables.conf`,
|
||||
`/var/lib/hy2xs/install-state.json` — общие, и две одновременные операции
|
||||
записывают их поверх друг друга, после чего откат одной «восстанавливает»
|
||||
состояние поверх изменений другой.
|
||||
|
||||
Отказ происходит **до** первой мутации, поэтому сервер не тронут. Что делать:
|
||||
|
||||
```bash
|
||||
# кто держит замок
|
||||
cat /run/lock/hy2xs-orchestrator.lock
|
||||
|
||||
# что делает держатель
|
||||
ps -o pid,etime,cmd -p "$(sed -n 's/.*"pid": *\([0-9]*\).*/\1/p' /run/lock/hy2xs-orchestrator.lock)"
|
||||
```
|
||||
|
||||
Дождитесь завершения. Замок снимается сам при любом завершении держателя,
|
||||
включая `Ctrl+C`, SIGTERM и обрыв SSH, а мёртвого держателя следующая операция
|
||||
обнаруживает и переиспользует замок самостоятельно.
|
||||
|
||||
Удалять файл руками нужно ровно в одном случае — если оркестратор сообщил, что
|
||||
содержимое замка не является корректной записью:
|
||||
|
||||
```text
|
||||
operation lock … exists but is not a valid HY2XS lock record
|
||||
```
|
||||
|
||||
Такой замок сознательно не снимается автоматически: непонятый файл не
|
||||
доказывает, что операции нет.
|
||||
|
||||
`status` и `diagnostics collect` замок не берут и работают во время операции.
|
||||
В отчёте `status` при этом появляется поле `operation_in_progress` — читайте
|
||||
состояние как снимок незавершённой транзакции, а не как итог.
|
||||
|
||||
### Установка отказала с `firewall_guard_fired`
|
||||
|
||||
```text
|
||||
automatic firewall rollback has already fired
|
||||
phase: firewall_guard_fired
|
||||
```
|
||||
|
||||
Это означает: автоматический откат firewall сработал раньше, чем операция успела
|
||||
снять guard. Сервер жив и доступен, но работает на **прежнем** firewall, а не на
|
||||
том, который сгенерировала операция. Именно поэтому фиксация успеха запрещена,
|
||||
даже если smoke успел сойтись, — иначе сервер считался бы настроенным с чужими
|
||||
правилами, что особенно дорого при смене порта Hysteria, SSH или ACME.
|
||||
|
||||
Окно guard — 45 секунд, и оно не обязано покрывать smoke. Причину ищите в том,
|
||||
почему проход в него не уложился:
|
||||
|
||||
```bash
|
||||
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
|
||||
journalctl -u hysteria-server -u hy2xs-admin --since '-10 min' --no-pager
|
||||
```
|
||||
|
||||
Обычная причина — медленный старт одного из сервисов. После устранения
|
||||
root-cause операция запускается заново; откат уже вернул сервер в исходное
|
||||
состояние.
|
||||
|
||||
### Сервер установился, но UI не работает
|
||||
Проверить:
|
||||
- разложился ли bundled UI
|
||||
|
||||
@@ -81,6 +81,29 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
|
||||
| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки |
|
||||
| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью |
|
||||
| `manual recovery data preserved at /run/hy2xs/rollback/<op>` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути |
|
||||
| `firewall rollback guard armed: … fires in 45s` | guard взведён; с этого момента операция обязана снять его до фиксации успеха |
|
||||
| `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания |
|
||||
| `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом |
|
||||
| `firewall rollback guard <unit> is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd |
|
||||
|
||||
Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая
|
||||
smoke и не обязано его покрывать: доказательством служит не время, а маркер
|
||||
|
||||
```text
|
||||
/run/hy2xs/rollback/<op-id>/auto-rollback-fired
|
||||
```
|
||||
|
||||
Если он есть — операция откатывается независимо от результата smoke, и в
|
||||
маркере установки появляется `phase: firewall_guard_fired`. Это значит: сервер
|
||||
жив и работает на прежнем firewall, а причину, по которой проход не уложился в
|
||||
окно, надо искать в journal — обычно это медленный старт одного из сервисов.
|
||||
|
||||
Юнит автоотката при частичном восстановлении уходит в `failed`, поэтому его
|
||||
стоит прочитать целиком:
|
||||
|
||||
```bash
|
||||
journalctl -u 'hy2xs-fw-rollback-*' --no-pager
|
||||
```
|
||||
|
||||
Резервные копии не удаляются, пока восстановление не подтверждено, и переживают
|
||||
долговечную запись `phase: installed`. Поэтому при разборе неудачи всегда
|
||||
@@ -92,6 +115,8 @@ ls -la /etc/hy2xs/backups/ # копия состояния до пос
|
||||
cat /etc/hy2xs/backups/*/manifest.json
|
||||
```
|
||||
|
||||
Имя каталога совпадает с полем `op_id` в `/var/lib/hy2xs/install-state.json`.
|
||||
|
||||
Манифест прямо говорит, какие файлы существовали до операции, а какие нет:
|
||||
запись `"present": false` означает, что откат обязан был файл **удалить**, а не
|
||||
восстановить.
|
||||
@@ -100,6 +125,47 @@ cat /etc/hy2xs/backups/*/manifest.json
|
||||
проблему внутри отката: последняя — это информация о том, что осталось не
|
||||
восстановленным, а не причина отказа.
|
||||
|
||||
## 8a. Одна операция за раз
|
||||
|
||||
`install`, `reconfigure`, `repair` и `doctor` сериализованы эксклюзивным
|
||||
замком:
|
||||
|
||||
```text
|
||||
/run/lock/hy2xs-orchestrator.lock
|
||||
```
|
||||
|
||||
Вторая операция отказывает сразу и **до первой мутации** — до снятия резервной
|
||||
копии, до записи конфигов, до firewall:
|
||||
|
||||
```text
|
||||
another HY2XS operation is already in progress: reconfigure (pid 4242, started at …)
|
||||
```
|
||||
|
||||
`doctor` тоже берёт замок: диагностика в середине транзакции описывает
|
||||
промежуточное состояние сервера и выдаёт бессмысленные ошибки по временным
|
||||
несоответствиям.
|
||||
|
||||
`status` и `diagnostics collect` замок **не** берут — они нужны в том числе во
|
||||
время долгой операции, — но сообщают о ней:
|
||||
|
||||
```bash
|
||||
hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package
|
||||
# "operation_in_progress": "reconfigure (pid 4242, started at …)"
|
||||
```
|
||||
|
||||
Замок снимается при любом завершении держателя: штатном, по `Ctrl+C`, по
|
||||
SIGTERM от systemd и при обрыве SSH. Если процесс был убит `kill -9`, следующая
|
||||
операция обнаружит мёртвого держателя и переиспользует замок сама:
|
||||
|
||||
```text
|
||||
operation lock … is held by install (pid 1234), which is no longer running; reclaiming it
|
||||
```
|
||||
|
||||
`/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае.
|
||||
Удалять файл руками нужно только если в нём оказалось непонятное содержимое:
|
||||
такой замок сознательно не переиспользуется автоматически — непонятый файл не
|
||||
доказывает, что операции нет.
|
||||
|
||||
## 9. Reconfigure flow
|
||||
|
||||
```bash
|
||||
|
||||
@@ -152,9 +152,17 @@ sudo ./purge-v0.sh --apply --yes-i-know
|
||||
`/var/lib/hysteria` (там остаётся ACME-состояние и сертификаты Hysteria),
|
||||
`/usr/local/lib/hy2xs` и symlink `/usr/local/bin/hy2xs-orchestrator`;
|
||||
5. удаляет `/usr/local/bin/hysteria`;
|
||||
6. удаляет фрагмент `/etc/nftables.d/hy2xs.nft` и строку `include` для него из
|
||||
6. удаляет артефакты незавершённой операции в `/run`: каталог отката
|
||||
`/run/hy2xs` (прежние `nftables.conf`, `hy2xs.nft` и маркер срабатывания
|
||||
guard) и замок операций `/run/lock/hy2xs-orchestrator.lock`, который может
|
||||
пережить убитый `kill -9` процесс оркестратора и не дать запуститься
|
||||
следующей установке. `/run` — tmpfs, и перезагрузка убрала бы оба, но
|
||||
очистка не имеет права требовать перезагрузки;
|
||||
7. удаляет `*.candidate` firewall — они остаются, если операция упала между
|
||||
`nft -c` и подстановкой файла в production-путь;
|
||||
8. удаляет фрагмент `/etc/nftables.d/hy2xs.nft` и строку `include` для него из
|
||||
`/etc/nftables.conf`, после чего перезагружает ruleset;
|
||||
7. проверяет чистоту хоста.
|
||||
9. проверяет чистоту хоста.
|
||||
|
||||
Список удаляемых путей и список legacy-маркеров clean-host контракта описывают
|
||||
одну и ту же границу: расхождение между ними ловится приёмкой сборки. Иначе
|
||||
@@ -194,6 +202,13 @@ sudo rm -rf /etc/hy2xs /etc/hysteria /var/lib/hy2xs /var/lib/hy2xs-admin \
|
||||
/usr/local/lib/hy2xs /usr/local/h-ui
|
||||
sudo rm -f /usr/local/bin/hysteria /usr/local/bin/hy2xs-orchestrator
|
||||
|
||||
# артефакты незавершённой операции в /run (tmpfs)
|
||||
sudo rm -rf /run/hy2xs
|
||||
sudo rm -f /run/lock/hy2xs-orchestrator.lock
|
||||
|
||||
# candidate-файлы firewall от операции, упавшей до подстановки
|
||||
sudo rm -f /etc/nftables.conf.candidate /etc/nftables.d/hy2xs.nft.candidate
|
||||
|
||||
sudo rm -f /etc/nftables.d/hy2xs.nft
|
||||
sudo sed -i '/nftables.d\/hy2xs.nft/d' /etc/nftables.conf
|
||||
sudo nft -c -f /etc/nftables.conf && sudo nft -f /etc/nftables.conf
|
||||
|
||||
@@ -87,6 +87,20 @@ PATHS=(
|
||||
/usr/local/lib/hy2xs
|
||||
/usr/local/h-ui
|
||||
/usr/local/bin/hy2xs-orchestrator
|
||||
# Артефакты незавершённой операции в /run. Каталог отката хранит прежние
|
||||
# nftables.conf и hy2xs.nft вместе с маркером срабатывания guard, а замок
|
||||
# операций способен пережить убитый `kill -9` процесс оркестратора и не даст
|
||||
# запуститься следующей установке. /run — tmpfs, и перезагрузка убрала бы оба,
|
||||
# но очистка не имеет права требовать перезагрузки.
|
||||
/run/hy2xs
|
||||
/run/lock/hy2xs-orchestrator.lock
|
||||
)
|
||||
|
||||
# Candidate-файлы firewall: остаются на диске, если операция упала между
|
||||
# проверкой `nft -c` и подстановкой в production-путь.
|
||||
CANDIDATES=(
|
||||
/etc/nftables.conf.candidate
|
||||
/etc/nftables.d/hy2xs.nft.candidate
|
||||
)
|
||||
|
||||
NFT_FRAGMENT=/etc/nftables.d/hy2xs.nft
|
||||
@@ -119,7 +133,7 @@ show_plan() {
|
||||
|
||||
log "будут удалены пути:"
|
||||
local path
|
||||
for path in "${UNIT_FILES[@]}" "${PATHS[@]}" "$NFT_FRAGMENT"; do
|
||||
for path in "${UNIT_FILES[@]}" "${PATHS[@]}" "${CANDIDATES[@]}" "$NFT_FRAGMENT"; do
|
||||
if [ -e "$path" ]; then
|
||||
printf ' - %s (существует)\n' "$path"
|
||||
else
|
||||
@@ -167,6 +181,10 @@ remove_paths() {
|
||||
rm -rf "$path"
|
||||
done
|
||||
|
||||
for path in "${CANDIDATES[@]}"; do
|
||||
rm -f "$path"
|
||||
done
|
||||
|
||||
rm -f /usr/local/bin/hysteria
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user