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
+62
View File
@@ -385,6 +385,68 @@ run_clean_install_acceptance() {
[ "$unguarded_runner" -ge 4 ] \
|| fail "acceptance: every mutating runner must ask the read-only guard for permission"
log_step "Acceptance: doctor is read-only by runtime invariant, not by convention"
# Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию.
# Всё остальное в smoke держалось на выборе раннера автором правки: читающие
# команды (`test -s`, `grep -q`, `stat`, `sudo -u ... test`, `nft -c`) шли
# через мутирующий namespace, поэтому настоящая мутация, случайно добавленная
# в smoke, ничем бы от них не отличалась и была бы разрешена в doctor молча.
grep -q 'enableReadOnlyGuard' orchestrator/src/commands/doctor.ts \
|| fail "acceptance: doctor обязан выполняться под read-only guard, а не только выставлять skipServiceStart"
grep -q 'disableReadOnlyGuard' orchestrator/src/commands/doctor.ts \
|| fail "acceptance: doctor обязан снимать guard в finally"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/commands/doctor.ts", "utf8");
const enable = source.indexOf("enableReadOnlyGuard(");
const work = source.indexOf("await readText(options.sourceConfigPath)");
const finallyAt = source.indexOf("} finally {");
const disable = source.indexOf("disableReadOnlyGuard()");
if (enable < 0 || work < 0 || finallyAt < 0 || disable < 0) {
throw new Error("не найдены включение guard, работа и finally");
}
if (enable > work) throw new Error("guard включается после начала работы");
if (disable < finallyAt) throw new Error("guard снимается не в finally");
' || fail "acceptance: guard doctor обязан охватывать весь проход и сниматься в finally"
# В smoke допустим ровно один мутирующий вызов — рестарт под флагом, который
# doctor выставляет принудительно.
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8");
const calls = source.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("//"))
.filter((line) => /\brunMutating[A-Za-z]*`/.test(line));
if (calls.length !== 1) {
throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n"));
}
if (!calls[0].includes("systemctl restart hysteria-server hy2xs-admin")) {
throw new Error("единственный мутирующий вызов smoke — не рестарт сервисов: " + calls[0]);
}
' || fail "acceptance: наблюдение в smoke обязано выполняться read-only раннерами"
# Пауза между двумя чтениями не является операцией над хостом.
! grep -Eq 'runMutating[A-Za-z]*`sleep' orchestrator/src/steps/smoke.ts \
|| fail "acceptance: ожидание между попытками снова выполняется мутирующим раннером"
log_step "Acceptance: reset-admin tells a storage failure apart from a missing admin"
# Склейка ErrStorage и ErrAdminUserNotFound уводила команду в ветку СОЗДАНИЯ:
# при транзиентном отказе чтения на сервере появлялась вторая рабочая учётка
# с паролем, уже напечатанным на экран.
grep -q 'errors.Is(err, dao.ErrAdminUserNotFound)' apps/cmd/reset.go \
|| fail "acceptance: reset-admin обязан различать sentinel-ошибки слоя данных"
! code_without_comments apps/cmd/reset.go | grep -qF 'hash, _ :=' \
|| fail "acceptance: ошибка хеширования пароля снова проглатывается, в password_hash уедет пустая строка"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/reset.go", "utf8");
const start = source.indexOf("func resetAdminCredentials");
if (start < 0) throw new Error("resetAdminCredentials отсутствует");
const body = source.slice(start);
if (!/default:\s*\n\s*return fmt\.Errorf/.test(body)) {
throw new Error("неизвестная ошибка чтения не приводит к отказу");
}
const save = body.indexOf("dao.SaveAdminUser(");
const notFound = body.indexOf("errors.Is(err, dao.ErrAdminUserNotFound)");
if (save < 0 || notFound < 0) throw new Error("не найдены ветка создания и её условие");
if (notFound > save) throw new Error("создание учётной записи не ограничено веткой «записи нет»");
' || fail "acceptance: создание администратора допустимо только при подтверждённом отсутствии записи"
log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor"
grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \
|| fail "acceptance: preflight must verify that the public endpoint resolves to this server"