fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+386
View File
@@ -0,0 +1,386 @@
# D0-D2. Живой сервер и fault injection
Часть набора проверок HY2XS. Карта всех частей — [docs/testing/README.md](README.md).
## D0. Граница установки на живом сервере
Проверяется на хосте, где уже стоит предыдущая установка:
1. `install.sh` завершается отказом на PHASE 0;
2. `/usr/local/lib/hy2xs` **не создан и не изменён**;
3. `/var/lib/hy2xs/install-state.json` не перезаписан;
4. `hysteria-server` и `hy2xs-admin` остались `active`;
5. в тексте отказа перечислены найденные маркеры и указан
`docs/operations/14-legacy-cleanup.md`;
6. после `tools/legacy/purge-v0.sh --apply --yes-i-know` установка проходит.
Пункты 2–4 — прямая регрессия: прежний установщик успевал переписать
`/usr/local/lib/hy2xs` и `install-state.json`, а затем откатом останавливал и
выключал работающие службы старой установки.
## D1. Отказ сразу после успешной PHASE 0 (fault injection)
Проверяется на чистом хосте. Это узкая щель между «PHASE 0 прошла» и «первая
мутирующая операция упала» — место, где установщик раньше врал.
1. PHASE 0 проходит успешно;
2. `installDeps` ломается искусственно (например, недоступный apt-репозиторий
или временно испорченный `/etc/apt/sources.list.d/`);
3. установка завершается отказом;
4. в выводе **нет** `fatal_pre_apply` и нет фразы про «ничего не применялось»;
5. `/var/lib/hy2xs/install-state.json` существует и честно показывает
`phase: failed` с текстом ошибки;
6. `owned_paths` в маркере содержит `/usr/local/lib/hy2xs`,
`/usr/local/bin/hy2xs-orchestrator` и `/usr/local/lib/hy2xs/package`
всё, что операция действительно создала;
7. diagnostics-бандл собран;
8. `hy2xs-orchestrator status` не заявляет установку успешной.
До исправления шаги 4–7 давали противоположный результат: `install-state.json`
уже лежал на диске, но отказ классифицировался как pre-apply, обработка
состояния пропускалась, а следующая установка на этой машине отказывалась по
clean-host контракту из-за оставшегося маркера.
Пункт 6 закрывает вторую половину той же щели. Пока раскладку оркестратора и
runtime-пакета выполнял `install.sh`, эти пути не принадлежали никому: они не
попадали в `owned_paths`, а отказ **второго** preflight (сменился DNS, занялся
порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не
изменено» — при уже созданном каталоге оркестратора.
## D1b. Откат при невозможности записать состояние отказа (fault injection)
Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния
больше не стоит перед восстановлением.
**Тайминг здесь — часть сценария, и его легко испортить.**
`tmpfs` НЕЛЬЗЯ монтировать заранее: первая же запись маркера (`preflight_ok`)
получит `ENOSPC`, установка отвалится до firewall, и проверяться будет совсем
другой путь — обычный `fatal_post_apply` на ранней стадии.
Порядок строго такой:
1. запустить установку и дождаться в журнале `step=firewall status=done`;
2. **только теперь**, во втором терминале:
```bash
mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs
dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true
```
3. вызвать искусственный отказ следующего шага установки.
Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и
через отказ на более длинном шаге (`smoke`), где времени заметно больше:
дождаться `step=smoke checks`, смонтировать `tmpfs` и остановить один из
сервисов, чтобы smoke не сошёлся.
Сценарий:
1. установка доходит **дальше** шага firewall (то есть `firewallTouched`
взведён, правила применены);
2. следующий шаг ломается искусственно;
3. запись `phase: failed` в маркер падает по `ENOSPC`;
4. в журнале есть `failed to persist failure state, continuing with the
mandatory rollback`;
5. **откат всё равно выполняется**: `rollbackFirewallNow` снимает применённые
правила, `/etc/nftables.conf` возвращается к прежнему состоянию, а
развёрнутые этой операцией юниты останавливаются и выключаются;
6. SSH остаётся доступным;
7. в журнале перечислены отказавшие стадии отката, если они были, и наружу
ушла **исходная** ошибка операции, а не `ENOSPC`.
До исправления шаги 4–6 давали противоположный результат: бросок из записи
состояния уносил управление наружу, и сервер оставался с применённым firewall
неудавшейся установки.
Тот же сценарий повторяется для `reconfigure`, где цена выше: там откат
дополнительно возвращает конфиги из `/etc/hy2xs/backups`, и оба восстановления
отменялись разом.
Дополнительно проверяется независимость стадий: если сделать неработоспособной
первую стадию (например, сделать `/etc/nftables.conf` неперезаписываемым через
`chattr +i` между применением firewall и отказом), восстановление конфигов и
остановка сервисов обязаны выполниться всё равно, а в журнале обязаны появиться
`rollback stage "…" failed, continuing with the remaining stages` и итоговое
`rollback finished with N failed stage(s)`.
## D1c. Данные отката переживают отказ фиксации успеха
Проверяется на чистом хосте. Это второй сценарий того же класса, но на
противоположном конце операции: отказывает не промежуточный шаг, а **запись
успеха**.
1. установка доходит до успешного `smoke`, в маркере появляется
`phase: smoke_ok`;
2. сразу после этого `/var/lib/hy2xs` делается недоступным для записи (тот же
`tmpfs`, смонтированный по появлению `step=smoke checks status=done`);
3. запись `phase: installed` падает;
4. `/run/hy2xs/rollback/<op>/prepared` и обе резервные копии firewall **всё ещё
существуют** — это и есть проверяемое свойство;
5. откат выполняется полностью: `/etc/nftables.conf` возвращается к прежнему
содержимому, развёрнутые юниты останавливаются;
6. в журнале **нет** строки `no HY2XS rollback markers found`.
До исправления пункты 4–6 давали противоположный результат: снятие таймера и
удаление копий выполнял один вызов, стоявший до записи `installed`, поэтому
откат запускался, но откатывать ему было нечем.
Обратная проверка — успешный путь: после нормально завершённой установки
`/run/hy2xs/rollback/` пуст, а `phase: installed` записан.
## D1d. Отказ снятия резервной копии останавливает reconfigure до мутации
Проверяется на рабочей установке.
1. `/etc/hy2xs/backups` делается недоступным для записи (`chattr +i` или
заполненный `tmpfs`);
2. запускается `reconfigure --apply`;
3. операция отказывает на шаге `backup` с сообщением про несозданную копию;
4. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` **не
изменены**, сервисы не перезапускались.
Отдельно проверяется привязка копии к операции: после успешного `reconfigure`
в `/etc/hy2xs/backups/` остаётся ровно один каталог — текущей операции — с
`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: … fires in 45s (timer accuracy 1s)`.
Пока guard ждёт, свойства таймера проверяются напрямую — обещанное окно
обязано быть контрактом systemd, а не намерением:
```bash
systemctl show hy2xs-fw-rollback-<op-id>.timer \
-p ActiveState -p SubState -p AccuracyUSec -p RemainAfterElapse
```
Ожидается `ActiveState=active`, `SubState=waiting`, `AccuracyUSec=1s`,
`RemainAfterElapse=no`. Без явной точности systemd вправе сработать в окне
`[45s; 45s + AccuracySec]`, а умолчание `AccuracySec=` — одна минута, то
есть реальное окно было бы 45–105 секунд;
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 об идущей операции
```
Отдельно проверяется, что замок не переживает своего держателя. **Важно:**
прерывать операцию нужно ДО шага `firewall`, иначе проверяется уже сценарий
D1h, а не этот.
1. `reconfigure --apply` прерывается `Ctrl+C` на шаге `config generation` —
замок снят, следующий `reconfigure` проходит;
2. процесс убивается `kill -9` на том же шаге, после чего следующая операция
сообщает `is held by … which is no longer running; reclaiming it` и
продолжает;
3. `/run/lock/hy2xs-orchestrator.lock` не остаётся после завершения операции.
## D1h. Аварийно умершая операция с вооружённым guard
Проверяется на рабочей установке. Это стык двух защитных механизмов, и до его
закрытия каждый из них по отдельности работал правильно, а вместе они
оставляли дыру.
Замок защищает production paths, пока **жив процесс-держатель**. Rollback guard
firewall — отдельный systemd-объект, который свой процесс переживает. Поэтому:
```text
A берёт замок -> применяет firewall -> вооружает guard на 45 секунд
A аварийно умирает
B берёт замок (снятый обработчиком сигнала либо переиспользованный)
B начинает менять production paths
guard A срабатывает и возвращает firewall, который был ДО A
```
Уникальные `op-id` здесь не помогают: каталоги копий разные, а
`/etc/nftables.conf`, `/etc/nftables.d/hy2xs.nft` и ruleset в ядре — общие.
Сценарий:
1. `reconfigure --apply` доводится до появления в журнале
`firewall rollback guard armed`;
2. процесс убивается `kill -9` (замок остаётся устаревшим) — и, отдельным
прогоном, `kill -TERM` (замок снимается обработчиком, то есть его вообще не
будет; это и есть случай, который проверка живости держателя не ловит);
3. **до истечения 45 секунд** запускается `repair` или `reconfigure --apply`;
4. новая операция обязана отказать:
```text
previous HY2XS operation is no longer running, but its firewall rollback guard
is still armed: hy2xs-fw-rollback-<op-id>.timer (active/waiting)
```
5. отказ происходит **до** снятия резервной копии и до первой мутации;
6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине;
7. после срабатывания guard транзиентный таймер выгружается
(`RemainAfterElapse=no`), и `repair` проходит. Проверяется наблюдением, а не
ожиданием на глаз:
```bash
systemctl show hy2xs-fw-rollback-<op-id>.timer -p LoadState -p ActiveState
systemctl list-units --all --plain 'hy2xs-fw-rollback-*'
```
Ожидается, что таймера в списке больше нет; оставшийся `.service` в
состоянии `failed` (частичное восстановление) операцию не блокирует.
Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну
операцию не задерживает, а `failed` от уже отработавшего guard **не** считается
непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия.
Отдельная проверка того же барьера — недоказуемое состояние. Барьер обязан
различать «guard вооружён» и «спросить не удалось»: это разные утверждения, и
оператору по ним нужны разные действия.
1. на рабочей установке без вооружённого guard делается недоступным запрос к
systemd — проще всего временно подложить в `PATH` оркестратора `systemctl`,
завершающийся ненулевым кодом;
2. любая операция жизненного цикла (`repair`, `reconfigure --apply`, `doctor`,
`install.sh` на PHASE 0) обязана отказать:
```text
unable to verify firewall rollback guard state; systemd query failed,
refusing to start a lifecycle operation
```
3. отказ происходит **до** первой мутации, и тип ошибки —
`GuardStateUnknownError`, а не `PendingRecoveryError`: ждать окна отката
здесь бессмысленно;
4. `hy2xs-orchestrator status` при этом **не** падает: он замок не берёт и
существует в том числе для сломанного хоста, поэтому сообщает
`rollback_guard_state: "unknown"` и `firewall_state: "guard_unknown"`;
5. после возврата рабочего `systemctl` операция проходит без дополнительных
действий.
Смысл проверки — в том, что прежнее поведение было противоположным: отказ
запроса давал пустой список guard'ов, барьер считал систему спокойной и
пропускал операцию, а взведённый таймер предыдущей операции срабатывал уже
посреди неё.
## D1g. Успешная установка не оставляет следов транзакции
Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка
к D1c и D1e: она ловит противоположную ошибку — данные транзакции, пережившие
её завершение.
После `installed`:
```text
systemctl list-units --all --plain '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. Проход установки не спотыкается о собственный маркер
Проверяется на чистом хосте, обычной успешной установкой.
1. `install.sh` доходит до `preflight capabilities` **после** `apt-get`;
2. установка на этом шаге **не** падает с текстом «обнаружена предыдущая или
посторонняя установка»;
3. установка доходит до `installed`.
Это сценарий, который не воспроизводится ни на одном dry-run: clean-host внутри
`install` проверялся дважды, и ко второму разу на диске уже лежал собственный
`/var/lib/hy2xs/install-state.json`, записанный после первого preflight. Каждая
чистая установка падала сразу после `apt-get`, получала `fatal_post_apply` и
оставляла сервер наполовину настроенным. Структурно закреплено в
`orchestrator/test/install-sequence.test.ts`.
## D2. Устаревший DNS после смены IPv4 провайдером
Проверяется на рабочей установке.
```text
сервер: текущий публичный IPv4 = B
DNS: A-запись = A (старый адрес)
hy2xs-orchestrator doctor
→ FAIL
→ в выводе присутствуют и A, и B
обновить A-запись на B, дождаться TTL
hy2xs-orchestrator doctor
→ PASS
```
Дополнительно: `reconfigure --apply` при устаревшей A-записи тоже обязан
отказать — инвариант живёт в общем `preflight`, а не в одном `doctor`.