From b22b4b0d993c445ac3083ece611e5640a2705f2e Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 30 Aug 2026 18:28:39 +0500 Subject: [PATCH] =?UTF-8?q?fix(v1):=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20read-only=20=D1=81=D0=B2=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=BE=D0=BC=20doctor,=20=D0=B0=20sentinel-=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B8=20=E2=80=94=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два свойства были описаны в документации, но не обеспечены кодом. 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: до неё появление дубликата было ненаблюдаемым. --- CHANGELOG.md | 38 +++- apps/cmd/reset.go | 111 +++++++---- apps/cmd/reset_test.go | 226 ++++++++++++++++++++++ apps/dao/admin_user.go | 16 ++ docs/11-testing-and-acceptance.md | 30 ++- docs/12-operations-and-troubleshooting.md | 11 ++ orchestrator/src/commands/doctor.ts | 64 ++++-- orchestrator/src/steps/smoke.ts | 67 ++++--- orchestrator/test/doctor-readonly.test.ts | 102 +++++++++- tools/build/lib/acceptance.sh | 62 ++++++ 10 files changed, 632 insertions(+), 95 deletions(-) create mode 100644 apps/cmd/reset_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e342209..f361435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-хешем. Команда восстановления доступа умела молча его отобрать. ### Исправлено — восстановление после неудачной операции diff --git a/apps/cmd/reset.go b/apps/cmd/reset.go index 4f1df02..7f0e6f0 100644 --- a/apps/cmd/reset.go +++ b/apps/cmd/reset.go @@ -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)) } diff --git a/apps/cmd/reset_test.go b/apps/cmd/reset_test.go new file mode 100644 index 0000000..691fe29 --- /dev/null +++ b/apps/cmd/reset_test.go @@ -0,0 +1,226 @@ +package cmd + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "hy2xs-admin/dao" + "hy2xs-admin/model/entity" + "hy2xs-admin/util" +) + +// reset-admin — команда восстановления доступа, и ошибиться ей дороже, чем +// обычному обработчику: она печатает новые учётные данные на экран и на этом +// основании оператор считает доступ восстановленным. +// +// Здесь закрепляются два дефекта: +// +// 1. отказ хранилища трактовался как «администратора нет», то есть переводил +// команду в ветку СОЗДАНИЯ учётной записи; +// 2. ошибка bcrypt в ветке обновления проглатывалась (`hash, _ := ...`), и в +// password_hash уезжала пустая строка. + +func newAdminDB(t *testing.T) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") + if err := dao.InitSqliteDBAt(dbPath); err != nil { + t.Fatalf("не удалось открыть тестовую базу: %v", err) + } + if err := dao.RunMigrations(); err != nil { + t.Fatalf("не удалось применить миграции: %v", err) + } + t.Cleanup(func() { _ = dao.CloseSqliteDB() }) +} + +func realLookup() (entity.AdminUser, error) { + return dao.GetAdminUser("1 = 1") +} + +func countAdmins(t *testing.T) int64 { + t.Helper() + + count, err := dao.CountAdminUsers() + if err != nil { + t.Fatalf("не удалось посчитать администраторов: %v", err) + } + return count +} + +func TestResetCreatesAdminWhenNoneExists(t *testing.T) { + newAdminDB(t) + + if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil { + t.Fatalf("сброс не выполнен: %v", err) + } + + if got := countAdmins(t); got != 1 { + t.Fatalf("ожидалась одна учётная запись, получено %d", got) + } + + admin, err := dao.GetAdminUser("1 = 1") + if err != nil { + t.Fatalf("учётная запись не читается: %v", err) + } + if *admin.Username != "operator-1" { + t.Errorf("имя пользователя не записано: %q", *admin.Username) + } + if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) { + t.Error("напечатанный пароль не подходит к сохранённому хешу") + } + if *admin.ForcePasswordChange != 1 { + t.Error("восстановительный пароль обязан требовать смены при первом входе") + } +} + +func TestResetUpdatesExistingAdminInPlace(t *testing.T) { + newAdminDB(t) + + if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil { + t.Fatalf("первичный сброс не выполнен: %v", err) + } + before, err := dao.GetAdminUser("1 = 1") + if err != nil { + t.Fatalf("учётная запись не читается: %v", err) + } + + if err := resetAdminCredentials(realLookup, "operator-2", "recovery-password-2", 1700000001000); err != nil { + t.Fatalf("повторный сброс не выполнен: %v", err) + } + + if got := countAdmins(t); got != 1 { + t.Fatalf("повторный сброс размножил учётные записи: %d", got) + } + + after, err := dao.GetAdminUser("1 = 1") + if err != nil { + t.Fatalf("учётная запись не читается: %v", err) + } + if *after.Id != *before.Id { + t.Errorf("учётная запись пересоздана: было id=%d, стало id=%d", *before.Id, *after.Id) + } + if *after.Username != "operator-2" { + t.Errorf("имя пользователя не обновлено: %q", *after.Username) + } + if !util.VerifyPassword("recovery-password-2", *after.PasswordHash) { + t.Error("новый пароль не подходит к сохранённому хешу") + } + if util.VerifyPassword("recovery-password-1", *after.PasswordHash) { + t.Error("прежний пароль всё ещё действует") + } + // Смена пароля обязана обесценивать выданные ранее токены. + if *after.TokenVersion <= *before.TokenVersion { + t.Errorf("token_version не увеличен: было %d, стало %d", *before.TokenVersion, *after.TokenVersion) + } +} + +// Ключевая регрессия. База ПОЛНОСТЬЮ работоспособна, отказало только чтение — +// ровно тот транзиентный случай («database is locked»), из-за которого прежний +// код уходил в ветку создания и оставлял на сервере вторую рабочую учётку с +// паролем, уже напечатанным на экран. +func TestResetRefusesToCreateSecondAdminOnStorageFailure(t *testing.T) { + newAdminDB(t) + + if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil { + t.Fatalf("первичный сброс не выполнен: %v", err) + } + before, err := dao.GetAdminUser("1 = 1") + if err != nil { + t.Fatalf("учётная запись не читается: %v", err) + } + + failingLookup := func() (entity.AdminUser, error) { + return entity.AdminUser{}, dao.ErrStorage + } + + err = resetAdminCredentials(failingLookup, "operator-2", "recovery-password-2", 1700000001000) + if err == nil { + t.Fatal("отказ хранилища обязан останавливать сброс, а не трактоваться как отсутствие администратора") + } + if !strings.Contains(err.Error(), "база данных не ответила") { + t.Errorf("сообщение не объясняет причину отказа: %v", err) + } + + if got := countAdmins(t); got != 1 { + t.Fatalf("при отказе чтения создана вторая учётная запись: всего %d", got) + } + + after, err := dao.GetAdminUser("1 = 1") + if err != nil { + t.Fatalf("учётная запись не читается: %v", err) + } + if *after.Id != *before.Id || *after.Username != *before.Username { + t.Error("существующая учётная запись изменена при отказе чтения") + } + if !util.VerifyPassword("recovery-password-1", *after.PasswordHash) { + t.Error("прежний пароль перестал действовать, хотя сброс не выполнялся") + } + if util.VerifyPassword("recovery-password-2", *after.PasswordHash) { + t.Error("напечатанный при отказе пароль действует") + } +} + +// «Записи нет» по-прежнему означает создание: строгость к ErrStorage не имеет +// права сломать штатный путь восстановления на пустой базе. +func TestResetStillCreatesOnNotFoundSentinel(t *testing.T) { + newAdminDB(t) + + notFound := func() (entity.AdminUser, error) { + return entity.AdminUser{}, dao.ErrAdminUserNotFound + } + + if err := resetAdminCredentials(notFound, "operator-1", "recovery-password-1", 1700000000000); err != nil { + t.Fatalf("сброс на пустой базе не выполнен: %v", err) + } + if got := countAdmins(t); got != 1 { + t.Fatalf("ожидалась одна учётная запись, получено %d", got) + } +} + +// Sentinel'ы «нет записи» намеренно НЕСУТ ОДИНАКОВЫЙ ТЕКСТ: WrongPassword +// уезжает в ответ Hysteria при неудачной machine-auth, и менять его ради +// внутренней аккуратности было бы изменением внешнего контракта. Поэтому +// различать их можно только через errors.Is, и решение о ветке обязано +// опираться на идентичность значения, а не на строку. +func TestAdminSentinelsAreDistinguishableOnlyByIdentity(t *testing.T) { + if dao.ErrAdminUserNotFound.Error() != dao.ErrPeerNotFound.Error() { + t.Log("тексты sentinel'ов разошлись; сравнение по идентичности остаётся обязательным") + } + if errors.Is(dao.ErrAdminUserNotFound, dao.ErrPeerNotFound) { + t.Error("sentinel'ы разных таблиц неразличимы по идентичности") + } + if errors.Is(dao.ErrStorage, dao.ErrAdminUserNotFound) { + t.Error("отказ хранилища опознаётся как отсутствие записи") + } + if dao.IsNotFound(dao.ErrStorage) { + t.Error("IsNotFound истинна для отказа хранилища") + } +} + +// Ошибка хеширования не имеет права превратиться в пустой password_hash. +func TestResetRefusesWhenPasswordCannotBeHashed(t *testing.T) { + newAdminDB(t) + + if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil { + t.Fatalf("первичный сброс не выполнен: %v", err) + } + + // HashPassword отклоняет пароль короче шести символов. + err := resetAdminCredentials(realLookup, "operator-2", "abc", 1700000001000) + if err == nil { + t.Fatal("непригодный пароль обязан останавливать сброс") + } + + admin, getErr := dao.GetAdminUser("1 = 1") + if getErr != nil { + t.Fatalf("учётная запись не читается: %v", getErr) + } + if !util.IsBcryptHash(*admin.PasswordHash) { + t.Errorf("в password_hash оказалась не-bcrypt строка: %q", *admin.PasswordHash) + } + if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) { + t.Error("прежний пароль перестал действовать после неудачного сброса") + } +} diff --git a/apps/dao/admin_user.go b/apps/dao/admin_user.go index c78acc7..208791d 100644 --- a/apps/dao/admin_user.go +++ b/apps/dao/admin_user.go @@ -20,6 +20,22 @@ func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, err return admin, nil } +// CountAdminUsers — сколько учётных записей администратора существует. +// +// Продукт допускает ровно одну, и это ЕДИНСТВЕННОЕ место, где такой вопрос +// можно задать: `GetAdminUser` берёт First() и о наличии второй строки не +// сообщает. Именно поэтому появление дубликата (ветка создания, выбранная при +// отказе чтения) было ненаблюдаемым — вторая рабочая учётка с уже напечатанным +// на экран паролем просто существовала, и никакой запрос об этом не говорил. +func CountAdminUsers() (int64, error) { + var count int64 + if tx := sqliteDB.Model(&entity.AdminUser{}).Count(&count); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return 0, ErrStorage + } + return count, nil +} + func SaveAdminUser(admin entity.AdminUser) (int64, error) { if tx := sqliteDB.Save(&admin); tx.Error != nil { logrus.Errorf("%v", tx.Error) diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index da1e600..2c3b75d 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -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` diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index c4a5441..7d17927 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -259,6 +259,17 @@ Update the DNS A record before using this server. hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех подключённых клиентов. +Безопасность здесь — инвариант рантайма, а не свойство текущего кода. `doctor` +целиком выполняется под тем же read-only guard, что и PHASE 0 установки: любая +запись в файл и любой мутирующий вызов под ним отказывают. Раньше от рестарта +защищал один принудительный флаг, а остальные проверки smoke — чтение прав, +владельцев и синтаксиса `nftables` — выполнялись мутирующими раннерами, поэтому +настоящая мутация, случайно добавленная в smoke, была бы разрешена молча. + +При этом диагностика не сужается: слушатели, `healthz`, права на файлы, machine +auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и +синтаксис `nft` проверяются полностью. + Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline; осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. diff --git a/orchestrator/src/commands/doctor.ts b/orchestrator/src/commands/doctor.ts index 5e8e353..edffb7a 100644 --- a/orchestrator/src/commands/doctor.ts +++ b/orchestrator/src/commands/doctor.ts @@ -1,5 +1,6 @@ import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import { readText } from "../lib/fs"; +import { disableReadOnlyGuard, enableReadOnlyGuard } from "../lib/guard"; import { info, setOperationContext, step, stepDone } from "../lib/log"; import { parseRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; @@ -79,31 +80,56 @@ async function checkSshForwardingForLocalUi(uiBindHost: string): Promise { * Остальная часть smoke — слушатели, права на файлы, machine auth, семантика * /etc/hysteria/config.yaml, версия бинаря — состояние сервера не меняет и * выполняется полностью. + * + * Второе: «не меняет» теперь инвариант рантайма, а не свойство текущего кода. + * + * Принудительный skipServiceStart закрывал ровно одну известную мутацию — + * рестарт сервисов. Всё остальное в smoke держалось на том, что автор правки + * выбрал правильный раннер: читающие команды (`test -s`, `grep -q`, `stat`, + * `sudo -u ... test`, `nft -c`) шли через мутирующий namespace, поэтому + * настоящая мутация, случайно добавленная в smoke, ничем бы не отличалась от + * них и была бы разрешена в doctor молча. + * + * Теперь эти команды классифицированы честно, а doctor целиком выполняется под + * тем же read-only guard'ом, что и PHASE 0 установки. Свойство + * + * doctor не изменяет диагностируемую систему + * + * стало проверяемым: любая запись через lib/fs и любой мутирующий раннер под + * ним отказывают. */ export async function doctor(options: ReconfigureOptions): Promise { setOperationContext(`doctor-${Date.now().toString(36)}`); - const configRaw = await readText(options.sourceConfigPath); - const config = parseRuntimeEnv(configRaw); + enableReadOnlyGuard("hy2xs-orchestrator doctor (диагностика не изменяет систему)"); - const context: ReconfigureContext = { - mode: "reconfigure", - options: { ...options, dryRun: true, apply: false, skipServiceStart: true }, - config, - packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), - packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), - installDate: new Date().toISOString(), - hysteriaVersion: await readInstalledHysteriaVersion(), - hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown") - }; + try { + const configRaw = await readText(options.sourceConfigPath); + const config = parseRuntimeEnv(configRaw); - step("doctor preflight"); - await preflight(context); - stepDone("doctor preflight"); + const context: ReconfigureContext = { + mode: "reconfigure", + options: { ...options, dryRun: true, apply: false, skipServiceStart: true }, + config, + packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), + packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), + installDate: new Date().toISOString(), + hysteriaVersion: await readInstalledHysteriaVersion(), + hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown") + }; - await checkSshForwardingForLocalUi(context.config.uiBindHost); + step("doctor preflight"); + await preflight(context); + stepDone("doctor preflight"); - step("doctor smoke"); - await smoke(context); - stepDone("doctor smoke"); + await checkSshForwardingForLocalUi(context.config.uiBindHost); + + step("doctor smoke"); + await smoke(context); + stepDone("doctor smoke"); + } finally { + // Guard снимается явно: процесс оркестратора выполняет одну команду за + // запуск, но оставлять глобальное состояние за собой команда не должна. + disableReadOnlyGuard(); + } } diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index 0fab8a4..3226c25 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -1,7 +1,7 @@ import type { RuntimeContext } from "../types/context"; import { info } from "../lib/log"; import { readText } from "../lib/fs"; -import { runMutatingHidden, runReadOnlySecret, runMutatingVisible } from "../lib/process"; +import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process"; import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; @@ -52,7 +52,10 @@ async function retry( } if (i < attempts - 1) { info(`${label}: retry ${i + 1}/${attempts}`); - await runMutatingHidden`sleep ${Math.max(1, Math.ceil(delayMs / 1000))}`; + // Ожидание — не операция над хостом. Здесь стоял `sleep` через мутирующий + // раннер: подпроцесс ради таймера, который вдобавок классифицировал паузу + // между двумя чтениями как изменение системы. + await Bun.sleep(delayMs); } } throw errorFactory(lastValue, lastError); @@ -117,36 +120,44 @@ export async function smoke(context: RuntimeContext): Promise { (response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`), ); - await runMutatingVisible`/usr/local/bin/hysteria version`; - await runMutatingVisible`test -s /etc/hysteria/config.yaml`; - await runMutatingVisible`test -s /etc/hy2xs/hy2xs.env`; - await runMutatingVisible`test -s /etc/hysteria/post-install.env`; - await runMutatingVisible`test -s ${context.config.bootstrapAdminSecretPath}`; - await runMutatingVisible`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`; - await runMutatingVisible`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`; - await runMutatingVisible`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`; - await runMutatingVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`; - await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`; - await runMutatingVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; - await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`; - await runMutatingVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`; - await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`; - await runMutatingVisible`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`; - await runMutatingVisible`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`; - await runMutatingVisible`sudo -u hysteria test -r /etc/hysteria/config.yaml`; - await runMutatingVisible`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`; - await runMutatingVisible`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`; - await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`; - await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`; - await runMutatingVisible`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`; + // Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами. + // + // Раньше эти проверки шли через runMutatingVisible: `test -s`, `grep -q`, + // `stat`, `sudo -u ... test` и `nft -c` объявлялись мутациями. Пока смысл + // выбора раннера держался на внимательности автора правки, свойство «doctor + // не меняет диагностируемую систему» невозможно было включить guard'ом — он + // отказал бы на первой же читающей команде. Классификация здесь — часть + // контракта, а не стиль. + info(`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`); + await runReadOnly`test -s /etc/hysteria/config.yaml`; + await runReadOnly`test -s /etc/hy2xs/hy2xs.env`; + await runReadOnly`test -s /etc/hysteria/post-install.env`; + await runReadOnly`test -s ${context.config.bootstrapAdminSecretPath}`; + await runReadOnly`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`; + await runReadOnly`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`; + await runReadOnly`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`; + await runReadOnly`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`; + await runReadOnly`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`; + await runReadOnly`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; + await runReadOnly`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`; + await runReadOnly`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`; + await runReadOnly`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`; + await runReadOnly`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`; + await runReadOnly`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`; + await runReadOnly`sudo -u hysteria test -r /etc/hysteria/config.yaml`; + await runReadOnly`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`; + await runReadOnly`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`; + await runReadOnly`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`; + await runReadOnly`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`; + await runReadOnly`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`; if (context.config.uiBindHost === "127.0.0.1") { const tcp = await runReadOnlySecret`ss -H -ltn`; if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) { throw new Error(`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`); } } - await runMutatingVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; - await runMutatingVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; + await runReadOnly`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; + await runReadOnly`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; // Путь machine-auth берётся из профиля, а не пишется здесь литералом: это // тот же контракт, который уезжает в /etc/hysteria/config.yaml. @@ -210,7 +221,9 @@ export async function smoke(context: RuntimeContext): Promise { throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`); } - await runMutatingVisible`nft -c -f /etc/nftables.conf`; + // `nft -c` только разбирает файл и правил не применяет — это проверка + // синтаксиса, а не изменение ruleset. + await runReadOnly`nft -c -f /etc/nftables.conf`; // Семантическая проверка установленного конфига: разбираем YAML и сверяем // с production-профилем, а не ищем подстроки. diff --git a/orchestrator/test/doctor-readonly.test.ts b/orchestrator/test/doctor-readonly.test.ts index 1dc7cab..98d1d2e 100644 --- a/orchestrator/test/doctor-readonly.test.ts +++ b/orchestrator/test/doctor-readonly.test.ts @@ -1,21 +1,35 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard"; +import { writeText } from "../src/lib/fs"; +import { runMutating, runMutatingHidden, runMutatingVisible } from "../src/lib/process"; /** * doctor обязан диагностировать установку, не изменяя её. * - * Регрессия, которую закрепляет этот файл. doctor собирал контекст с - * `skipServiceStart` по умолчанию (false) и звал общий smoke, а тот первым же - * действием выполняет `systemctl restart hysteria-server hy2xs-admin`. Команда, - * которую runbook предлагает запускать при подозрении на проблему, обрывала все - * живые VPN-соединения на сервере — в том числе когда с сервисом всё в порядке. + * Первая регрессия. doctor собирал контекст с `skipServiceStart` по умолчанию + * (false) и звал общий smoke, а тот первым же действием выполняет + * `systemctl restart hysteria-server hy2xs-admin`. Команда, которую runbook + * предлагает запускать при подозрении на проблему, обрывала все живые + * VPN-соединения на сервере — в том числе когда с сервисом всё в порядке. * - * Проверяется исходник, а не поведение: единственная альтернатива — поднять - * настоящие systemd-юниты, чего в этой среде не существует. Утверждение при - * этом точное: рестарт в smoke стоит ровно под одним условием, и тест - * фиксирует, что doctor это условие закрывает безусловно. + * Вторая — уровнем глубже. Принудительный `skipServiceStart` закрывал ровно + * одну ИЗВЕСТНУЮ мутацию. Всё остальное в smoke держалось на выборе раннера + * автором правки, а читающие команды (`test -s`, `grep -q`, `stat`, + * `sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть + * настоящая мутация, случайно добавленная в smoke, ничем бы от них не + * отличалась и была бы разрешена в doctor молча. + * + * Часть утверждений проверяется разбором исходника: поднять настоящие + * systemd-юниты и nftables в этой среде нельзя. Утверждения при этом точные — + * рестарт в smoke стоит ровно под одним условием, и мутирующий раннер в smoke + * ровно один. */ + +afterEach(() => { + disableReadOnlyGuard(); +}); // Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и // из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик // обычно второе. @@ -64,3 +78,71 @@ describe("doctor не перезапускает сервисы", () => { expect(doctorSource).toContain("await smoke(context)"); }); }); + +describe("read-only — инвариант doctor, а не свойство текущего кода", () => { + test("doctor включает read-only guard и снимает его в finally", () => { + const doctorSource = source("commands/doctor.ts"); + + expect(doctorSource).toContain("enableReadOnlyGuard("); + expect(doctorSource).toContain("disableReadOnlyGuard()"); + + // Guard обязан включаться ДО первой работы и сниматься в finally: иначе + // отказ любой проверки оставил бы глобальное состояние взведённым. + const enable = doctorSource.indexOf("enableReadOnlyGuard("); + const body = doctorSource.indexOf("await readText(options.sourceConfigPath)"); + const finallyAt = doctorSource.indexOf("} finally {"); + const disable = doctorSource.indexOf("disableReadOnlyGuard()"); + + expect(enable).toBeLessThan(body); + expect(finallyAt).toBeGreaterThan(-1); + expect(disable).toBeGreaterThan(finallyAt); + }); + + // Ровно один мутирующий вызов на весь smoke, и он под флагом, который doctor + // выставляет принудительно. Всё остальное классифицировано как наблюдение. + test("в smoke не осталось мутирующих раннеров, кроме рестарта под флагом", () => { + const smokeSource = source("steps/smoke.ts"); + const calls = smokeSource + .split(/\r?\n/) + .filter((line) => !line.trimStart().startsWith("//")) + .filter((line) => /\brunMutating[A-Za-z]*`/.test(line)); + + expect(calls, `лишние мутирующие вызовы в smoke: ${calls.join("; ")}`).toHaveLength(1); + expect(calls[0]).toContain("systemctl restart hysteria-server hy2xs-admin"); + }); + + // Регрессия: ожидание между двумя чтениями выполнялось подпроцессом `sleep` + // через мутирующий раннер, то есть пауза объявлялась изменением системы. + test("ожидание между попытками не является операцией над хостом", () => { + const smokeSource = source("steps/smoke.ts"); + expect(smokeSource).not.toMatch(/runMutating[A-Za-z]*`sleep/); + expect(smokeSource).toContain("await Bun.sleep(delayMs)"); + }); + + test("проверки прав и синтаксиса классифицированы как наблюдение", () => { + const smokeSource = source("steps/smoke.ts"); + for (const observation of [ + "runReadOnly`test -s /etc/hysteria/config.yaml`", + "runReadOnly`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`", + "runReadOnly`nft -c -f /etc/nftables.conf`" + ]) { + expect(smokeSource).toContain(observation); + } + }); + + // Поведенческая часть: механизм, на который опирается doctor, действительно + // отказывает любой мутации и называет причину. + test("под guard'ом doctor любая мутация отказывает с его причиной", async () => { + enableReadOnlyGuard("hy2xs-orchestrator doctor (диагностика не изменяет систему)"); + + expect(isReadOnlyGuardActive()).toBe(true); + for (const call of [ + () => runMutating`systemctl restart hysteria-server`, + () => runMutatingVisible`nft -f /etc/nftables.conf`, + () => runMutatingHidden`install -d /var/lib/hy2xs`, + () => writeText("/etc/hysteria/config.yaml", "x") + ]) { + await expect(call()).rejects.toThrow(/read-only guard violation.*doctor/s); + } + }); +}); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index f033fa6..0b52428 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -385,6 +385,68 @@ run_clean_install_acceptance() { [ "$unguarded_runner" -ge 4 ] \ || fail "acceptance: every mutating runner must ask the read-only guard for permission" + log_step "Acceptance: doctor is read-only by runtime invariant, not by convention" + # Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию. + # Всё остальное в smoke держалось на выборе раннера автором правки: читающие + # команды (`test -s`, `grep -q`, `stat`, `sudo -u ... test`, `nft -c`) шли + # через мутирующий namespace, поэтому настоящая мутация, случайно добавленная + # в smoke, ничем бы от них не отличалась и была бы разрешена в doctor молча. + grep -q 'enableReadOnlyGuard' orchestrator/src/commands/doctor.ts \ + || fail "acceptance: doctor обязан выполняться под read-only guard, а не только выставлять skipServiceStart" + grep -q 'disableReadOnlyGuard' orchestrator/src/commands/doctor.ts \ + || fail "acceptance: doctor обязан снимать guard в finally" + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/commands/doctor.ts", "utf8"); + const enable = source.indexOf("enableReadOnlyGuard("); + const work = source.indexOf("await readText(options.sourceConfigPath)"); + const finallyAt = source.indexOf("} finally {"); + const disable = source.indexOf("disableReadOnlyGuard()"); + if (enable < 0 || work < 0 || finallyAt < 0 || disable < 0) { + throw new Error("не найдены включение guard, работа и finally"); + } + if (enable > work) throw new Error("guard включается после начала работы"); + if (disable < finallyAt) throw new Error("guard снимается не в finally"); + ' || fail "acceptance: guard doctor обязан охватывать весь проход и сниматься в finally" + # В smoke допустим ровно один мутирующий вызов — рестарт под флагом, который + # doctor выставляет принудительно. + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8"); + const calls = source.split(/\r?\n/) + .filter((line) => !line.trimStart().startsWith("//")) + .filter((line) => /\brunMutating[A-Za-z]*`/.test(line)); + if (calls.length !== 1) { + throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n")); + } + if (!calls[0].includes("systemctl restart hysteria-server hy2xs-admin")) { + throw new Error("единственный мутирующий вызов smoke — не рестарт сервисов: " + calls[0]); + } + ' || fail "acceptance: наблюдение в smoke обязано выполняться read-only раннерами" + # Пауза между двумя чтениями не является операцией над хостом. + ! grep -Eq 'runMutating[A-Za-z]*`sleep' orchestrator/src/steps/smoke.ts \ + || fail "acceptance: ожидание между попытками снова выполняется мутирующим раннером" + + log_step "Acceptance: reset-admin tells a storage failure apart from a missing admin" + # Склейка ErrStorage и ErrAdminUserNotFound уводила команду в ветку СОЗДАНИЯ: + # при транзиентном отказе чтения на сервере появлялась вторая рабочая учётка + # с паролем, уже напечатанным на экран. + grep -q 'errors.Is(err, dao.ErrAdminUserNotFound)' apps/cmd/reset.go \ + || fail "acceptance: reset-admin обязан различать sentinel-ошибки слоя данных" + ! code_without_comments apps/cmd/reset.go | grep -qF 'hash, _ :=' \ + || fail "acceptance: ошибка хеширования пароля снова проглатывается, в password_hash уедет пустая строка" + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("apps/cmd/reset.go", "utf8"); + const start = source.indexOf("func resetAdminCredentials"); + if (start < 0) throw new Error("resetAdminCredentials отсутствует"); + const body = source.slice(start); + if (!/default:\s*\n\s*return fmt\.Errorf/.test(body)) { + throw new Error("неизвестная ошибка чтения не приводит к отказу"); + } + const save = body.indexOf("dao.SaveAdminUser("); + const notFound = body.indexOf("errors.Is(err, dao.ErrAdminUserNotFound)"); + if (save < 0 || notFound < 0) throw new Error("не найдены ветка создания и её условие"); + if (notFound > save) throw new Error("создание учётной записи не ограничено веткой «записи нет»"); + ' || fail "acceptance: создание администратора допустимо только при подтверждённом отсутствии записи" + log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor" grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \ || fail "acceptance: preflight must verify that the public endpoint resolves to this server"