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
+45 -19
View File
@@ -1,5 +1,6 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { readText } from "../lib/fs";
import { disableReadOnlyGuard, enableReadOnlyGuard } from "../lib/guard";
import { info, setOperationContext, step, stepDone } from "../lib/log";
import { parseRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
@@ -79,31 +80,56 @@ async function checkSshForwardingForLocalUi(uiBindHost: string): Promise<void> {
* Остальная часть smoke — слушатели, права на файлы, machine auth, семантика
* /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> {
setOperationContext(`doctor-${Date.now().toString(36)}`);
const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw);
enableReadOnlyGuard("hy2xs-orchestrator doctor (диагностика не изменяет систему)");
const context: ReconfigureContext = {
mode: "reconfigure",
options: { ...options, dryRun: true, apply: false, skipServiceStart: true },
config,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: await readInstalledHysteriaVersion(),
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
};
try {
const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw);
step("doctor preflight");
await preflight(context);
stepDone("doctor preflight");
const context: ReconfigureContext = {
mode: "reconfigure",
options: { ...options, dryRun: true, apply: false, skipServiceStart: true },
config,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: await readInstalledHysteriaVersion(),
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
};
await checkSshForwardingForLocalUi(context.config.uiBindHost);
step("doctor preflight");
await preflight(context);
stepDone("doctor preflight");
step("doctor smoke");
await smoke(context);
stepDone("doctor smoke");
await checkSshForwardingForLocalUi(context.config.uiBindHost);
step("doctor smoke");
await smoke(context);
stepDone("doctor smoke");
} finally {
// Guard снимается явно: процесс оркестратора выполняет одну команду за
// запуск, но оставлять глобальное состояние за собой команда не должна.
disableReadOnlyGuard();
}
}
+40 -27
View File
@@ -1,7 +1,7 @@
import type { RuntimeContext } from "../types/context";
import { info } from "../lib/log";
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 { assertHysteriaConfigMatchesProfile } from "./configAssertions";
@@ -52,7 +52,10 @@ async function retry<T>(
}
if (i < attempts - 1) {
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);
@@ -117,36 +120,44 @@ export async function smoke(context: RuntimeContext): Promise<void> {
(response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`),
);
await runMutatingVisible`/usr/local/bin/hysteria version`;
await runMutatingVisible`test -s /etc/hysteria/config.yaml`;
await runMutatingVisible`test -s /etc/hy2xs/hy2xs.env`;
await runMutatingVisible`test -s /etc/hysteria/post-install.env`;
await runMutatingVisible`test -s ${context.config.bootstrapAdminSecretPath}`;
await runMutatingVisible`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`;
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'`;
await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`;
await runMutatingVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`;
await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`;
await runMutatingVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`;
await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`;
await runMutatingVisible`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`;
await runMutatingVisible`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`;
await runMutatingVisible`sudo -u hysteria test -r /etc/hysteria/config.yaml`;
await runMutatingVisible`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`;
await runMutatingVisible`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`;
await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`;
await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`;
await runMutatingVisible`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`;
// Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами.
//
// Раньше эти проверки шли через runMutatingVisible: `test -s`, `grep -q`,
// `stat`, `sudo -u ... test` и `nft -c` объявлялись мутациями. Пока смысл
// выбора раннера держался на внимательности автора правки, свойство «doctor
// не меняет диагностируемую систему» невозможно было включить guard'ом — он
// отказал бы на первой же читающей команде. Классификация здесь — часть
// контракта, а не стиль.
info(`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`);
await runReadOnly`test -s /etc/hysteria/config.yaml`;
await runReadOnly`test -s /etc/hy2xs/hy2xs.env`;
await runReadOnly`test -s /etc/hysteria/post-install.env`;
await runReadOnly`test -s ${context.config.bootstrapAdminSecretPath}`;
await runReadOnly`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`;
await runReadOnly`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`;
await runReadOnly`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`;
await runReadOnly`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`;
await runReadOnly`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`;
await runReadOnly`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`;
await runReadOnly`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`;
await runReadOnly`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`;
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") {
const tcp = await runReadOnlySecret`ss -H -ltn`;
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}`);
}
}
await runMutatingVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`;
await runMutatingVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`;
await runReadOnly`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`;
await runReadOnly`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`;
// Путь machine-auth берётся из профиля, а не пишется здесь литералом: это
// тот же контракт, который уезжает в /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}`);
}
await runMutatingVisible`nft -c -f /etc/nftables.conf`;
// `nft -c` только разбирает файл и правил не применяет — это проверка
// синтаксиса, а не изменение ruleset.
await runReadOnly`nft -c -f /etc/nftables.conf`;
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
// с production-профилем, а не ищем подстроки.
+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);
}
});
});