From 39139e95f77d64ddbce04dfa0a287d9620151cac Mon Sep 17 00:00:00 2001 From: Crimson Date: Mon, 31 Aug 2026 01:31:15 +0500 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20guard=20=D0=B8=20=D0=B2=D0=B7?= =?UTF-8?q?=D0=B0=D0=B8=D0=BC=D0=BD=D0=BE=D0=B5=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: шестой проход. --- CHANGELOG.md | 91 +++++++++++ README.md | 22 +++ docs/07-systemd-and-firewall.md | 78 +++++++++- docs/11-testing-and-acceptance.md | 181 +++++++++++++++++++++- docs/12-operations-and-troubleshooting.md | 66 ++++++++ docs/13-production-runbook.md | 66 ++++++++ docs/14-legacy-cleanup.md | 19 ++- tools/legacy/purge-v0.sh | 20 ++- 8 files changed, 532 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759594f..3238777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,97 @@ Hardening-проход перед релизом `1.0.0`. Основная те запускался, но отдельные его шаги могли молча не выполнить восстановление, отчитаться успехом и уничтожить резервную копию. +Шестой проход — управление самой транзакцией, а не копированием файлов. +Предыдущие проходы сделали надёжными шаги операции; здесь закрываются два +допущения, на которых держалась операция целиком: что снятие защиты от отката +действительно произошло и что операция на сервере ровно одна. + +### Исправлено — границы транзакции + +- **Снятие rollback guard было утверждением, а не фактом.** Порядок фиксации + успеха выглядел так: + + ```text + systemctl stop .timer .service || true + -> "firewall rollback timer disarmed" + -> phase=installed + ``` + + Между «мы думаем, что guard снят» и «guard действительно снят» не было ни + одной проверки: `|| true` стирал код возврата, и взведённый таймер мог + вернуть прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать + `|| true` было нельзя — для транзиентного юнита, уже убранного systemd, + `systemctl stop` возвращает 5, и этот исход неотличим от успеха. + + Введён маркер `/run/hy2xs/rollback//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/`, который runbook + предлагает открыть, на сервере не существовал. + +- **Стадии восстановления `reconfigure` были независимы по группе, а не по + файлу.** Отказ `cp` для `hy2xs-admin.service` отменял восстановление + `hysteria-server.service`: внешняя стадия честно попадала в список + отказавших, но принцип «восстановить максимум» на уровне файлов не + выполнялся. + ### Исправлено — целостность отката - **Данные для отката уничтожались до фиксации успеха.** Успешный install diff --git a/README.md b/README.md index 1584fa7..8fa0c5c 100644 --- a/README.md +++ b/README.md @@ -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//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: diff --git a/docs/07-systemd-and-firewall.md b/docs/07-systemd-and-firewall.md index 129bb84..ea119d5 100644 --- a/docs/07-systemd-and-firewall.md +++ b/docs/07-systemd-and-firewall.md @@ -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//` и + **доказать**, что копия создана. Отказ здесь останавливает операцию до + первой мутации. +2. Подготовить candidate-файлы и проверить их `nft -c -f`. +3. Подставить candidate в production-пути. +4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-` + с окном 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//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, и этот исход неотличим от успешного снятия +взведённого таймера. + +Сам 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 с конфигурацией. ## Что не делаем diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 0a09a37..45ccfb8 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -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/` из 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-.service`, а на диске — + `/run/hy2xs/rollback//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; замок мёртвого держателя переиспользуется безопасно diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index 9ab6535..c359683 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -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 diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index 7094b31..6df2615 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -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/` | 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 is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd | + +Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая +smoke и не обязано его покрывать: доказательством служит не время, а маркер + +```text +/run/hy2xs/rollback//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 diff --git a/docs/14-legacy-cleanup.md b/docs/14-legacy-cleanup.md index 8b8c068..e62b520 100644 --- a/docs/14-legacy-cleanup.md +++ b/docs/14-legacy-cleanup.md @@ -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 diff --git a/tools/legacy/purge-v0.sh b/tools/legacy/purge-v0.sh index 23fda24..54d53f0 100644 --- a/tools/legacy/purge-v0.sh +++ b/tools/legacy/purge-v0.sh @@ -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 }