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:
2026-08-31 01:31:15 +05:00
parent 50ec4d9717
commit 39139e95f7
8 changed files with 532 additions and 11 deletions
+91
View File
@@ -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