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
+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 { 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 собирал контекст с
* `skipServiceStart` по умолчанию (false) и звал общий smoke, а тот первым же
* действием выполняет `systemctl restart hysteria-server hy2xs-admin`. Команда,
* которую runbook предлагает запускать при подозрении на проблему, обрывала все
* живые VPN-соединения на сервере — в том числе когда с сервисом всё в порядке.
* Первая регрессия. doctor собирал контекст с `skipServiceStart` по умолчанию
* (false) и звал общий smoke, а тот первым же действием выполняет
* `systemctl restart hysteria-server hy2xs-admin`. Команда, которую runbook
* предлагает запускать при подозрении на проблему, обрывала все живые
* VPN-соединения на сервере — в том числе когда с сервисом всё в порядке.
*
* Проверяется исходник, а не поведение: единственная альтернатива — поднять
* настоящие systemd-юниты, чего в этой среде не существует. Утверждение при
* этом точное: рестарт в smoke стоит ровно под одним условием, и тест
* фиксирует, что doctor это условие закрывает безусловно.
* Вторая — уровнем глубже. Принудительный `skipServiceStart` закрывал ровно
* одну ИЗВЕСТНУЮ мутацию. Всё остальное в smoke держалось на выборе раннера
* автором правки, а читающие команды (`test -s`, `grep -q`, `stat`,
* `sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть
* настоящая мутация, случайно добавленная в smoke, ничем бы от них не
* отличалась и была бы разрешена в doctor молча.
*
* Часть утверждений проверяется разбором исходника: поднять настоящие
* systemd-юниты и nftables в этой среде нельзя. Утверждения при этом точные —
* рестарт в smoke стоит ровно под одним условием, и мутирующий раннер в smoke
* ровно один.
*/
afterEach(() => {
disableReadOnlyGuard();
});
// Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и
// из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик
// обычно второе.
@@ -64,3 +78,71 @@ describe("doctor не перезапускает сервисы", () => {
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);
}
});
});