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:
@@ -146,8 +146,8 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh
|
||||
|
||||
`orchestrator/test/install-boundary.test.ts`:
|
||||
|
||||
- под read-only guard недоступны `writeText`, `writeTextAtomic` и все
|
||||
`runMutating*`-раннеры;
|
||||
- под read-only guard недоступны `writeText`, `writeTextAtomic`, `ensureDir` и
|
||||
все `runMutating*`-раннеры;
|
||||
- read-only раннеры под guard'ом продолжают работать: разделение API — это не
|
||||
запрет наблюдения, а запрет мутации;
|
||||
- классификация отказа зависит от ownership-флагов и фазы, а **не** от текста
|
||||
@@ -464,6 +464,29 @@ wildcard-маршрутом фронтенда или дублирующая р
|
||||
- `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`,
|
||||
отличимые через `errors.Is` от `ErrStorage`.
|
||||
|
||||
`apps/cmd/reset_test.go` — единственный оставшийся потребитель, который склеивал
|
||||
эти два ответа:
|
||||
|
||||
- на пустой базе `reset-admin` создаёт учётную запись, bcrypt-хеш подходит к
|
||||
напечатанному паролю, `force_password_change` выставлен;
|
||||
- поверх существующей записи обновление идёт **на месте**: тот же `id`, новый
|
||||
пароль, увеличенный `token_version`, прежний пароль больше не действует;
|
||||
- при отказе чтения (`ErrStorage`) сброс **останавливается**: вторая учётная
|
||||
запись не создаётся, существующая не меняется, напечатанный пароль не
|
||||
действует. База в этом тесте полностью работоспособна — воспроизводится ровно
|
||||
транзиентный отказ («database is locked»), при котором прежний код уходил в
|
||||
ветку создания и оставлял на сервере вторую рабочую учётку с уже
|
||||
напечатанным паролем;
|
||||
- при `ErrAdminUserNotFound` создание по-прежнему выполняется: строгость к
|
||||
отказу хранилища не имеет права сломать штатный путь восстановления;
|
||||
- непригодный для bcrypt пароль останавливает сброс, а не пишет пустую строку
|
||||
в `password_hash` — раньше ошибка хеширования проглатывалась
|
||||
(`hash, _ := util.HashPassword(...)`), и команда восстановления доступа
|
||||
молча его отбирала: `VerifyPassword` отклоняет всё, что не bcrypt;
|
||||
- sentinel'ы разных таблиц несут одинаковый текст (`WrongPassword` уезжает в
|
||||
ответ Hysteria и менять его нельзя), поэтому проверяется именно
|
||||
различимость через `errors.Is`, а не по строке.
|
||||
|
||||
## A10. Импорт пиров (unit)
|
||||
|
||||
`apps/service/peer_import_test.go`:
|
||||
@@ -973,7 +996,7 @@ hy2xs-orchestrator doctor
|
||||
32. документация нигде не утверждает, что Salamander — фиксированный инвариант
|
||||
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
|
||||
34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
|
||||
35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения
|
||||
35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения, и это обеспечено read-only guard'ом, а не соглашением о выборе раннера
|
||||
36. удаление `bootstrap-admin-peer` переживает `systemctl restart` и `reboot`: пир не воскресает
|
||||
37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска
|
||||
38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен
|
||||
@@ -993,3 +1016,4 @@ hy2xs-orchestrator doctor
|
||||
52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный
|
||||
53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле
|
||||
54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон
|
||||
55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash`
|
||||
|
||||
@@ -259,6 +259,17 @@ Update the DNS A record before using this server.
|
||||
hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех
|
||||
подключённых клиентов.
|
||||
|
||||
Безопасность здесь — инвариант рантайма, а не свойство текущего кода. `doctor`
|
||||
целиком выполняется под тем же read-only guard, что и PHASE 0 установки: любая
|
||||
запись в файл и любой мутирующий вызов под ним отказывают. Раньше от рестарта
|
||||
защищал один принудительный флаг, а остальные проверки smoke — чтение прав,
|
||||
владельцев и синтаксиса `nftables` — выполнялись мутирующими раннерами, поэтому
|
||||
настоящая мутация, случайно добавленная в smoke, была бы разрешена молча.
|
||||
|
||||
При этом диагностика не сужается: слушатели, `healthz`, права на файлы, machine
|
||||
auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и
|
||||
синтаксис `nft` проверяются полностью.
|
||||
|
||||
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
|
||||
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
|
||||
осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
|
||||
|
||||
Reference in New Issue
Block a user