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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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-профилем, а не ищем подстроки.
|
||||
|
||||
Reference in New Issue
Block a user