b22b4b0d99
Два свойства были описаны в документации, но не обеспечены кодом.
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: до неё появление дубликата было ненаблюдаемым.
146 lines
6.2 KiB
Go
146 lines
6.2 KiB
Go
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,
|
||
)
|
||
}
|
||
}
|