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 и релизные гейты: восстановление после Четвёртый проход — 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-хешем. Команда восстановления доступа умела молча его отобрать.
### Исправлено — восстановление после неудачной операции ### Исправлено — восстановление после неудачной операции
+77 -34
View File
@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"hy2xs-admin/dao" "hy2xs-admin/dao"
@@ -34,6 +35,15 @@ const (
resetPasswordLength = 24 resetPasswordLength = 24
) )
// adminLookup — способ узнать о существующей учётной записи администратора.
//
// Параметризовано ради теста на отказ хранилища. Отличить «ветку создания» от
// «ветки обновления» при недоступной базе иначе нельзя: при по-настоящему
// сломанной базе обе ветки заканчиваются ошибкой записи, и наблюдаемый
// результат совпадает. Опасен же ровно транзиентный отказ, когда чтение упало,
// а запись прошла.
type adminLookup func() (entity.AdminUser, error)
func runReset(cmd *cobra.Command, args []string) { func runReset(cmd *cobra.Command, args []string) {
username, err := util.RandomString(resetUsernameLength) username, err := util.RandomString(resetUsernameLength)
if err != nil { if err != nil {
@@ -49,18 +59,69 @@ func runReset(cmd *cobra.Command, args []string) {
fmt.Println(err.Error()) fmt.Println(err.Error())
os.Exit(1) 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 { 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) tokenVersion := int64(1)
status := int64(1) status := int64(1)
forcePasswordChange := int64(1) forcePasswordChange := int64(1)
passwordChangedAt := nowMs passwordChangedAt := nowMs
hash, hashErr := util.HashPassword(password)
if hashErr != nil {
fmt.Println(hashErr.Error())
os.Exit(1)
}
adminUser := entity.AdminUser{ adminUser := entity.AdminUser{
Username: &username, Username: &username,
PasswordHash: &hash, PasswordHash: &hash,
@@ -70,33 +131,15 @@ func runReset(cmd *cobra.Command, args []string) {
TokenVersion: &tokenVersion, TokenVersion: &tokenVersion,
} }
if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil { if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil {
fmt.Println(saveErr.Error()) return fmt.Errorf("не удалось создать учётную запись администратора: %w", saveErr)
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 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))
} }
+226
View File
@@ -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("прежний пароль перестал действовать после неудачного сброса")
}
}
+16
View File
@@ -20,6 +20,22 @@ func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, err
return admin, nil 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) { func SaveAdminUser(admin entity.AdminUser) (int64, error) {
if tx := sqliteDB.Save(&admin); tx.Error != nil { if tx := sqliteDB.Save(&admin); tx.Error != nil {
logrus.Errorf("%v", tx.Error) logrus.Errorf("%v", tx.Error)
+27 -3
View File
@@ -146,8 +146,8 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
`orchestrator/test/install-boundary.test.ts`: `orchestrator/test/install-boundary.test.ts`:
- под read-only guard недоступны `writeText`, `writeTextAtomic` и все - под read-only guard недоступны `writeText`, `writeTextAtomic`, `ensureDir` и
`runMutating*`-раннеры; все `runMutating*`-раннеры;
- read-only раннеры под guard'ом продолжают работать: разделение API — это не - read-only раннеры под guard'ом продолжают работать: разделение API — это не
запрет наблюдения, а запрет мутации; запрет наблюдения, а запрет мутации;
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста - классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
@@ -464,6 +464,29 @@ wildcard-маршрутом фронтенда или дублирующая р
- `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`, - `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`,
отличимые через `errors.Is` от `ErrStorage`. отличимые через `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) ## A10. Импорт пиров (unit)
`apps/service/peer_import_test.go`: `apps/service/peer_import_test.go`:
@@ -973,7 +996,7 @@ hy2xs-orchestrator doctor
32. документация нигде не утверждает, что Salamander — фиксированный инвариант 32. документация нигде не утверждает, что Salamander — фиксированный инвариант
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy 33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет» 34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения 35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения, и это обеспечено read-only guard'ом, а не соглашением о выборе раннера
36. удаление `bootstrap-admin-peer` переживает `systemctl restart` и `reboot`: пир не воскресает 36. удаление `bootstrap-admin-peer` переживает `systemctl restart` и `reboot`: пир не воскресает
37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска 37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска
38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен 38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен
@@ -993,3 +1016,4 @@ hy2xs-orchestrator doctor
52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный 52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле 53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле
54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон 54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон
55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash`
+11
View File
@@ -259,6 +259,17 @@ Update the DNS A record before using this server.
hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех
подключённых клиентов. подключённых клиентов.
Безопасность здесь — инвариант рантайма, а не свойство текущего кода. `doctor`
целиком выполняется под тем же read-only guard, что и PHASE 0 установки: любая
запись в файл и любой мутирующий вызов под ним отказывают. Раньше от рестарта
защищал один принудительный флаг, а остальные проверки smoke — чтение прав,
владельцев и синтаксиса `nftables` — выполнялись мутирующими раннерами, поэтому
настоящая мутация, случайно добавленная в smoke, была бы разрешена молча.
При этом диагностика не сужается: слушатели, `healthz`, права на файлы, machine
auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и
синтаксис `nft` проверяются полностью.
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline; публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
+45 -19
View File
@@ -1,5 +1,6 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { readText } from "../lib/fs"; import { readText } from "../lib/fs";
import { disableReadOnlyGuard, enableReadOnlyGuard } from "../lib/guard";
import { info, setOperationContext, step, stepDone } from "../lib/log"; import { info, setOperationContext, step, stepDone } from "../lib/log";
import { parseRuntimeEnv } from "../config/env"; import { parseRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
@@ -79,31 +80,56 @@ async function checkSshForwardingForLocalUi(uiBindHost: string): Promise<void> {
* Остальная часть smoke — слушатели, права на файлы, machine auth, семантика * Остальная часть smoke — слушатели, права на файлы, machine auth, семантика
* /etc/hysteria/config.yaml, версия бинаря — состояние сервера не меняет и * /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<void> { export async function doctor(options: ReconfigureOptions): Promise<void> {
setOperationContext(`doctor-${Date.now().toString(36)}`); setOperationContext(`doctor-${Date.now().toString(36)}`);
const configRaw = await readText(options.sourceConfigPath); enableReadOnlyGuard("hy2xs-orchestrator doctor (диагностика не изменяет систему)");
const config = parseRuntimeEnv(configRaw);
const context: ReconfigureContext = { try {
mode: "reconfigure", const configRaw = await readText(options.sourceConfigPath);
options: { ...options, dryRun: true, apply: false, skipServiceStart: true }, const config = parseRuntimeEnv(configRaw);
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")
};
step("doctor preflight"); const context: ReconfigureContext = {
await preflight(context); mode: "reconfigure",
stepDone("doctor preflight"); 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 checkSshForwardingForLocalUi(context.config.uiBindHost);
await smoke(context);
stepDone("doctor smoke"); step("doctor smoke");
await smoke(context);
stepDone("doctor smoke");
} finally {
// Guard снимается явно: процесс оркестратора выполняет одну команду за
// запуск, но оставлять глобальное состояние за собой команда не должна.
disableReadOnlyGuard();
}
} }
+40 -27
View File
@@ -1,7 +1,7 @@
import type { RuntimeContext } from "../types/context"; import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log"; import { info } from "../lib/log";
import { readText } from "../lib/fs"; 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 { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
@@ -52,7 +52,10 @@ async function retry<T>(
} }
if (i < attempts - 1) { if (i < attempts - 1) {
info(`${label}: retry ${i + 1}/${attempts}`); 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); throw errorFactory(lastValue, lastError);
@@ -117,36 +120,44 @@ export async function smoke(context: RuntimeContext): Promise<void> {
(response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`), (response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`),
); );
await runMutatingVisible`/usr/local/bin/hysteria version`; // Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами.
await runMutatingVisible`test -s /etc/hysteria/config.yaml`; //
await runMutatingVisible`test -s /etc/hy2xs/hy2xs.env`; // Раньше эти проверки шли через runMutatingVisible: `test -s`, `grep -q`,
await runMutatingVisible`test -s /etc/hysteria/post-install.env`; // `stat`, `sudo -u ... test` и `nft -c` объявлялись мутациями. Пока смысл
await runMutatingVisible`test -s ${context.config.bootstrapAdminSecretPath}`; // выбора раннера держался на внимательности автора правки, свойство «doctor
await runMutatingVisible`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`; // не меняет диагностируемую систему» невозможно было включить guard'ом — он
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'`; info(`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`);
await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`; await runReadOnly`test -s /etc/hysteria/config.yaml`;
await runMutatingVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`; await runReadOnly`test -s /etc/hysteria/post-install.env`;
await runMutatingVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`; await runReadOnly`test -s ${context.config.bootstrapAdminSecretPath}`;
await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`; await runReadOnly`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`;
await runMutatingVisible`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`; await runReadOnly`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`;
await runMutatingVisible`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`; await runReadOnly`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`;
await runMutatingVisible`sudo -u hysteria test -r /etc/hysteria/config.yaml`; await runReadOnly`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`;
await runMutatingVisible`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`; await runReadOnly`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`;
await runMutatingVisible`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`; await runReadOnly`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`;
await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`; await runReadOnly`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`;
await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`; await runReadOnly`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`;
await runMutatingVisible`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`; 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") { if (context.config.uiBindHost === "127.0.0.1") {
const tcp = await runReadOnlySecret`ss -H -ltn`; const tcp = await runReadOnlySecret`ss -H -ltn`;
if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) { 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}`); 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 runReadOnly`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`;
await runMutatingVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; await runReadOnly`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`;
// Путь machine-auth берётся из профиля, а не пишется здесь литералом: это // Путь machine-auth берётся из профиля, а не пишется здесь литералом: это
// тот же контракт, который уезжает в /etc/hysteria/config.yaml. // тот же контракт, который уезжает в /etc/hysteria/config.yaml.
@@ -210,7 +221,9 @@ export async function smoke(context: RuntimeContext): Promise<void> {
throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`); 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 и сверяем // Семантическая проверка установленного конфига: разбираем YAML и сверяем
// с production-профилем, а не ищем подстроки. // с production-профилем, а не ищем подстроки.
+92 -10
View File
@@ -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 { readFileSync } from "node:fs";
import { join } from "node:path"; 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 обязан диагностировать установку, не изменяя её.
* *
* Регрессия, которую закрепляет этот файл. doctor собирал контекст с * Первая регрессия. doctor собирал контекст с `skipServiceStart` по умолчанию
* `skipServiceStart` по умолчанию (false) и звал общий smoke, а тот первым же * (false) и звал общий smoke, а тот первым же действием выполняет
* действием выполняет `systemctl restart hysteria-server hy2xs-admin`. Команда, * `systemctl restart hysteria-server hy2xs-admin`. Команда, которую runbook
* которую runbook предлагает запускать при подозрении на проблему, обрывала все * предлагает запускать при подозрении на проблему, обрывала все живые
* живые VPN-соединения на сервере в том числе когда с сервисом всё в порядке. * VPN-соединения на сервере в том числе когда с сервисом всё в порядке.
* *
* Проверяется исходник, а не поведение: единственная альтернатива поднять * Вторая уровнем глубже. Принудительный `skipServiceStart` закрывал ровно
* настоящие systemd-юниты, чего в этой среде не существует. Утверждение при * одну ИЗВЕСТНУЮ мутацию. Всё остальное в smoke держалось на выборе раннера
* этом точное: рестарт в smoke стоит ровно под одним условием, и тест * автором правки, а читающие команды (`test -s`, `grep -q`, `stat`,
* фиксирует, что doctor это условие закрывает безусловно. * `sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть
* настоящая мутация, случайно добавленная в smoke, ничем бы от них не
* отличалась и была бы разрешена в doctor молча.
*
* Часть утверждений проверяется разбором исходника: поднять настоящие
* systemd-юниты и nftables в этой среде нельзя. Утверждения при этом точные
* рестарт в smoke стоит ровно под одним условием, и мутирующий раннер в smoke
* ровно один.
*/ */
afterEach(() => {
disableReadOnlyGuard();
});
// Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и // Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и
// из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик // из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик
// обычно второе. // обычно второе.
@@ -64,3 +78,71 @@ describe("doctor не перезапускает сервисы", () => {
expect(doctorSource).toContain("await smoke(context)"); 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);
}
});
});
+62
View File
@@ -385,6 +385,68 @@ run_clean_install_acceptance() {
[ "$unguarded_runner" -ge 4 ] \ [ "$unguarded_runner" -ge 4 ] \
|| fail "acceptance: every mutating runner must ask the read-only guard for permission" || 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" log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor"
grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \ grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \
|| fail "acceptance: preflight must verify that the public endpoint resolves to this server" || fail "acceptance: preflight must verify that the public endpoint resolves to this server"