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
+36 -2
View File
@@ -23,8 +23,42 @@ Hardening-проход перед релизом `1.0.0`. Основная те
невозможно воспользоваться.
Четвёртый проход — failure path и релизные гейты: восстановление после
неудачной установки, которое умело отменить само себя, и два гейта сборки,
проверявшие не то, что обещали.
неудачной установки, которое умело отменить само себя, два гейта сборки,
проверявшие не то, что обещали, и два свойства, которые были описаны, но не
обеспечены — read-only у `doctor` и различение отказа базы у `reset-admin`.
### Изменено — свойства, ставшие инвариантами
- **`doctor` read-only по инварианту рантайма, а не по соглашению.**
Принудительный `skipServiceStart` закрывал ровно одну ИЗВЕСТНУЮ мутацию —
рестарт сервисов. Всё остальное в `smoke` держалось на том, что автор правки
выбрал правильный раннер, а читающие команды (`test -s`, `grep -q`, `stat`,
`sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть
настоящая мутация, случайно добавленная в `smoke`, ничем бы от них не
отличалась и была бы разрешена в `doctor` молча.
Эти команды классифицированы честно, ожидание между попытками перестало быть
подпроцессом `sleep` через мутирующий раннер, а сам `doctor` целиком
выполняется под тем же read-only guard, что и PHASE 0 установки. Диагностика
при этом не сузилась.
### Исправлено — `reset-admin`
- **Отказ базы трактовался как «администратора нет».** Слой данных специально
различает `ErrAdminUserNotFound` и `ErrStorage`, но команда восстановления
доступа склеивала их обычным `if err != nil { создать } else { обновить }`.
Опасен здесь не только нарушенный смысл sentinel'ов: при транзиентном отказе
чтения («database is locked») ветка создания отрабатывала успешно, и в
таблице оказывались ДВЕ учётные записи администратора. `GetAdminUser` берёт
`First()` и о второй строке не сообщает — то есть на сервере оставалась
вторая рабочая учётка с паролем, уже напечатанным на экран, и ни один запрос
об этом не говорил.
- **Ошибка хеширования пароля проглатывалась.** В ветке обновления стояло
`hash, _ := util.HashPassword(password)` внутри литерала map. При отказе
bcrypt в `password_hash` уезжала пустая строка, а на экран печатался пароль,
которым войти уже невозможно: `VerifyPassword` отклоняет всё, что не является
bcrypt-хешем. Команда восстановления доступа умела молча его отобрать.
### Исправлено — восстановление после неудачной операции