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
|
||||
|
||||
Reference in New Issue
Block a user