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,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