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
+77 -34
View File
@@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"hy2xs-admin/dao"
@@ -34,6 +35,15 @@ const (
resetPasswordLength = 24
)
// adminLookup — способ узнать о существующей учётной записи администратора.
//
// Параметризовано ради теста на отказ хранилища. Отличить «ветку создания» от
// «ветки обновления» при недоступной базе иначе нельзя: при по-настоящему
// сломанной базе обе ветки заканчиваются ошибкой записи, и наблюдаемый
// результат совпадает. Опасен же ровно транзиентный отказ, когда чтение упало,
// а запись прошла.
type adminLookup func() (entity.AdminUser, error)
func runReset(cmd *cobra.Command, args []string) {
username, err := util.RandomString(resetUsernameLength)
if err != nil {
@@ -49,18 +59,69 @@ func runReset(cmd *cobra.Command, args []string) {
fmt.Println(err.Error())
os.Exit(1)
}
admin, err := dao.GetAdminUser("1 = 1")
nowMs := time.Now().UnixMilli()
lookup := func() (entity.AdminUser, error) { return dao.GetAdminUser("1 = 1") }
if err = resetAdminCredentials(lookup, username, password, time.Now().UnixMilli()); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.CloseSqliteDB(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
}
func resetAdminCredentials(lookup adminLookup, username, password string, nowMs int64) error {
// Хеш считается ОДИН раз и ДО записи.
//
// В ветке обновления стояло `hash, _ := util.HashPassword(password)` внутри
// литерала map. Ошибка bcrypt проглатывалась, в password_hash уезжала пустая
// строка, а на экран печатался пароль, которым войти уже невозможно:
// VerifyPassword отклоняет всё, что не является bcrypt-хешем. То есть
// команда восстановления доступа умела молча его отобрать.
hash, err := util.HashPassword(password)
if err != nil {
return fmt.Errorf("не удалось захешировать пароль восстановления: %w", err)
}
admin, err := lookup()
// Отказ хранилища — это не «администратора нет».
//
// Здесь стояло обычное `if err != nil { создать } else { обновить }`, хотя
// слой данных специально различает ErrAdminUserNotFound и ErrStorage.
// Склейка опасна не только нарушением смысла sentinel'ов: при транзиентном
// отказе SQLite («database is locked») ветка создания отрабатывала успешно,
// и в таблице оказывались ДВЕ учётные записи администратора.
// `GetAdminUser("1 = 1").First()` дальше отдаёт произвольную из них, то есть
// на сервере остаётся вторая рабочая учётка с паролем, который уже был
// напечатан на экран.
switch {
case err == nil:
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion + 1
}
if updateErr := dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
"username": username,
"password_hash": hash,
"force_password_change": 1,
"password_changed_at": nowMs,
"token_version": tokenVersion,
"status": 1,
}); updateErr != nil {
return fmt.Errorf("не удалось обновить учётную запись администратора: %w", updateErr)
}
return nil
case errors.Is(err, dao.ErrAdminUserNotFound):
tokenVersion := int64(1)
status := int64(1)
forcePasswordChange := int64(1)
passwordChangedAt := nowMs
hash, hashErr := util.HashPassword(password)
if hashErr != nil {
fmt.Println(hashErr.Error())
os.Exit(1)
}
adminUser := entity.AdminUser{
Username: &username,
PasswordHash: &hash,
@@ -70,33 +131,15 @@ func runReset(cmd *cobra.Command, args []string) {
TokenVersion: &tokenVersion,
}
if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil {
fmt.Println(saveErr.Error())
os.Exit(1)
}
} else {
tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
tokenVersion = *admin.TokenVersion + 1
}
if err = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
"username": username,
"password_hash": func() string {
hash, _ := util.HashPassword(password)
return hash
}(),
"force_password_change": 1,
"password_changed_at": nowMs,
"token_version": tokenVersion,
"status": 1,
}); err != nil {
fmt.Println(err.Error())
os.Exit(1)
return fmt.Errorf("не удалось создать учётную запись администратора: %w", saveErr)
}
return nil
default:
return fmt.Errorf(
"не удалось прочитать учётную запись администратора: база данных не ответила. "+
"Сброс не выполнен: создавать вторую учётную запись при недоступной базе нельзя: %w",
err,
)
}
if err = dao.CloseSqliteDB(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
}