fix(v1): сделать read-only свойством doctor, а sentinel-ошибки — решением

Два свойства были описаны в документации, но не обеспечены кодом.

1. doctor «не изменяет диагностируемую систему».

   Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию —
   рестарт сервисов. Всё остальное в smoke держалось на том, что автор правки
   выбрал правильный раннер: `test -s`, `grep -q`, `stat`, `sudo -u ... test`
   и `nft -c` шли через мутирующий namespace, хотя ничего не меняют. Ожидание
   между попытками выполнялось подпроцессом `sleep` через runMutatingHidden,
   то есть пауза между двумя чтениями объявлялась изменением системы.

   Следствие: настоящая мутация, случайно добавленная в smoke, ничем бы от них
   не отличалась и была бы разрешена в doctor молча — а включить guard было
   нельзя, он отказал бы на первой же читающей команде.

   Команды классифицированы честно, `sleep` заменён таймером, и doctor целиком
   выполняется под тем же read-only guard, что и PHASE 0 установки. Guard
   снимается в finally. Диагностика при этом не сузилась: слушатели, healthz,
   права, machine auth, trafficStats, версия бинаря, семантика конфига и
   синтаксис nft проверяются полностью.

2. reset-admin различает «администратора нет» и «база не ответила».

   Слой данных специально возвращает разные sentinel'ы, но команда склеивала их
   обычным `if err != nil { создать } else { обновить }`. Опасен здесь не
   только нарушенный смысл: при транзиентном отказе чтения («database is
   locked») ветка создания отрабатывала успешно, и в таблице оказывались ДВЕ
   учётные записи администратора. GetAdminUser берёт First() и о второй строке
   не сообщает — на сервере оставалась вторая рабочая учётка с паролем, уже
   напечатанным на экран, и ни один запрос об этом не говорил.

   Заодно исправлено проглатывание ошибки хеширования: в ветке обновления
   стояло `hash, _ := util.HashPassword(password)` внутри литерала map. При
   отказе bcrypt в password_hash уезжала пустая строка, а на экран печатался
   пароль, которым войти уже невозможно — VerifyPassword отклоняет всё, что не
   bcrypt. Команда восстановления доступа умела молча его отобрать.

Тесты: doctor-readonly.test.ts дополнен поведенческой проверкой guard и
контролем набора раннеров в smoke; apps/cmd/reset_test.go проверяет обе ветки
на настоящей SQLite и отказ чтения при полностью работоспособной базе — ровно
тот случай, который прежний код превращал во второго администратора. Добавлена
dao.CountAdminUsers: до неё появление дубликата было ненаблюдаемым.
This commit is contained in:
2026-08-30 18:28:39 +05:00
parent 594525dd73
commit b22b4b0d99
10 changed files with 632 additions and 95 deletions
+27 -3
View File
@@ -146,8 +146,8 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
`orchestrator/test/install-boundary.test.ts`:
- под read-only guard недоступны `writeText`, `writeTextAtomic` и все
`runMutating*`-раннеры;
- под read-only guard недоступны `writeText`, `writeTextAtomic`, `ensureDir` и
все `runMutating*`-раннеры;
- read-only раннеры под guard'ом продолжают работать: разделение API — это не
запрет наблюдения, а запрет мутации;
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
@@ -464,6 +464,29 @@ wildcard-маршрутом фронтенда или дублирующая р
- `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`,
отличимые через `errors.Is` от `ErrStorage`.
`apps/cmd/reset_test.go` — единственный оставшийся потребитель, который склеивал
эти два ответа:
- на пустой базе `reset-admin` создаёт учётную запись, bcrypt-хеш подходит к
напечатанному паролю, `force_password_change` выставлен;
- поверх существующей записи обновление идёт **на месте**: тот же `id`, новый
пароль, увеличенный `token_version`, прежний пароль больше не действует;
- при отказе чтения (`ErrStorage`) сброс **останавливается**: вторая учётная
запись не создаётся, существующая не меняется, напечатанный пароль не
действует. База в этом тесте полностью работоспособна — воспроизводится ровно
транзиентный отказ («database is locked»), при котором прежний код уходил в
ветку создания и оставлял на сервере вторую рабочую учётку с уже
напечатанным паролем;
- при `ErrAdminUserNotFound` создание по-прежнему выполняется: строгость к
отказу хранилища не имеет права сломать штатный путь восстановления;
- непригодный для bcrypt пароль останавливает сброс, а не пишет пустую строку
в `password_hash` — раньше ошибка хеширования проглатывалась
(`hash, _ := util.HashPassword(...)`), и команда восстановления доступа
молча его отбирала: `VerifyPassword` отклоняет всё, что не bcrypt;
- sentinel'ы разных таблиц несут одинаковый текст (`WrongPassword` уезжает в
ответ Hysteria и менять его нельзя), поэтому проверяется именно
различимость через `errors.Is`, а не по строке.
## A10. Импорт пиров (unit)
`apps/service/peer_import_test.go`:
@@ -973,7 +996,7 @@ hy2xs-orchestrator doctor
32. документация нигде не утверждает, что Salamander — фиксированный инвариант
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения
35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения, и это обеспечено read-only guard'ом, а не соглашением о выборе раннера
36. удаление `bootstrap-admin-peer` переживает `systemctl restart` и `reboot`: пир не воскресает
37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска
38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен
@@ -993,3 +1016,4 @@ hy2xs-orchestrator doctor
52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле
54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон
55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash`