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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user