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
+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-профилем, а не ищем подстроки.