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:
+36
-2
@@ -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
@@ -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 {
|
return nil
|
||||||
tokenVersion := int64(1)
|
|
||||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
default:
|
||||||
tokenVersion = *admin.TokenVersion + 1
|
return fmt.Errorf(
|
||||||
|
"не удалось прочитать учётную запись администратора: база данных не ответила. "+
|
||||||
|
"Сброс не выполнен: создавать вторую учётную запись при недоступной базе нельзя: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("прежний пароль перестал действовать после неудачного сброса")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,9 +80,29 @@ 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)}`);
|
||||||
|
enableReadOnlyGuard("hy2xs-orchestrator doctor (диагностика не изменяет систему)");
|
||||||
|
|
||||||
|
try {
|
||||||
const configRaw = await readText(options.sourceConfigPath);
|
const configRaw = await readText(options.sourceConfigPath);
|
||||||
const config = parseRuntimeEnv(configRaw);
|
const config = parseRuntimeEnv(configRaw);
|
||||||
|
|
||||||
@@ -105,5 +126,10 @@ export async function doctor(options: ReconfigureOptions): Promise<void> {
|
|||||||
step("doctor smoke");
|
step("doctor smoke");
|
||||||
await smoke(context);
|
await smoke(context);
|
||||||
stepDone("doctor smoke");
|
stepDone("doctor smoke");
|
||||||
|
} finally {
|
||||||
|
// Guard снимается явно: процесс оркестратора выполняет одну команду за
|
||||||
|
// запуск, но оставлять глобальное состояние за собой команда не должна.
|
||||||
|
disableReadOnlyGuard();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-профилем, а не ищем подстроки.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user