Files
founder b22b4b0d99 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: до неё появление дубликата было ненаблюдаемым.
2026-08-30 18:28:39 +05:00

146 lines
6.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"hy2xs-admin/dao"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
"os"
"time"
)
var resetCmd = &cobra.Command{
Use: "reset-admin",
Short: "Reset admin username and password",
Long: "Reset admin username and password.",
Run: runReset,
}
func init() {
rootCmd.AddCommand(resetCmd)
}
// Длины восстановительных учётных данных.
//
// Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36
// бит, то есть ровно нижняя граница, которую пропускает HashPassword, и
// единственной защитой оставался force_password_change. Пароль восстановления
// живёт до первого входа администратора, но до этого входа он полноценно
// пускает в панель, поэтому его стойкость должна соответствовать остальным
// секретам продукта, а не минимально допустимой.
const (
resetUsernameLength = 12
resetPasswordLength = 24
)
// adminLookup — способ узнать о существующей учётной записи администратора.
//
// Параметризовано ради теста на отказ хранилища. Отличить «ветку создания» от
// «ветки обновления» при недоступной базе иначе нельзя: при по-настоящему
// сломанной базе обе ветки заканчиваются ошибкой записи, и наблюдаемый
// результат совпадает. Опасен же ровно транзиентный отказ, когда чтение упало,
// а запись прошла.
type adminLookup func() (entity.AdminUser, error)
func runReset(cmd *cobra.Command, args []string) {
username, err := util.RandomString(resetUsernameLength)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
password, err := util.RandomString(resetPasswordLength)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.InitSql(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
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
adminUser := entity.AdminUser{
Username: &username,
PasswordHash: &hash,
Status: &status,
ForcePasswordChange: &forcePasswordChange,
PasswordChangedAt: &passwordChangedAt,
TokenVersion: &tokenVersion,
}
if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil {
return fmt.Errorf("не удалось создать учётную запись администратора: %w", saveErr)
}
return nil
default:
return fmt.Errorf(
"не удалось прочитать учётную запись администратора: база данных не ответила. "+
"Сброс не выполнен: создавать вторую учётную запись при недоступной базе нельзя: %w",
err,
)
}
}