fix(install): сделать границу «хост изменён» настоящим инвариантом

fatal_pre_apply мог означать «хост уже изменён». install-state.json пишется
сразу после успешного preflight, до установки пакетов, но классификация
отказа его не учитывала. Падение apt-get объявлялось как «на сервере ничего
не изменено»: откат и обработка состояния пропускались, а маркер оставался на
диске и ломал следующую установку по clean-host контракту.

Ownership-флаги переформулированы с «шаг успешно завершился» на «операция
могла начать менять систему» и взводятся перед мутирующим вызовом: apt-get
умеет изменить систему и упасть. fatal_pre_apply теперь недостижим ни при
одном взведённом флаге, включая stateWritten.

Read-only guard PHASE 0 можно было обойти. Guard стоял на writeText,
writeTextAtomic, runVisible, runHidden и runRawVisible, но не на универсальном
run, через который в коде проходили и наблюдение (ss, systemctl is-active), и
настоящие мутации (useradd, install -d, mkdir, cp -a, tar). Универсального
раннера больше нет: runReadOnly/runReadOnlySecret без guard'а и runMutating*
под guard'ом, выбор — явное решение на месте вызова.

clean-host не замечал часть того, что удаляет purge. /var/lib/hysteria с
ACME-состоянием Hysteria, /var/log/hy2xs, /usr/local/lib/hy2xs и
/usr/local/bin/hy2xs-orchestrator не были маркерами: сервер, где остался
только старый runtime-state Hysteria, проходил проверку и получал свежую
установку поверх чужого состояния. Пути, которые install.sh создаёт между
фазами, помечены как созданные установщиком, иначе PHASE 1 отказала бы на
собственном оркестраторе.

purge-v0.sh --keep-hysteria-binary противоречил установщику: скрипт сохранял
/usr/local/bin/hysteria и сообщал «хост чист для установки HY2XS v1», хотя
clean-host считает этот бинарник legacy-маркером. Флаг удалён.

DNS проверялся на существование A-записи, но не на то, куда она ведёт. После
принудительной смены IPv4 провайдером doctor отвечал успехом, хотя клиентская
ссылка отправляла людей на чужую машину. Проверялся при этом HY2XS_DOMAIN,
тогда как в hysteria2:// уезжает HY2XS_PUBLIC_HOST.

Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать
множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка
живёт в общем preflight, поэтому действует в install, reconfigure и doctor.
Адрес определяется локально, без внешних сервисов определения IP. Строгость
управляется HY2XS_PUBLIC_ENDPOINT_POLICY (strict по умолчанию); отсутствие
A-записи фатально при любом значении.

TS-санитайзер приведён к той же формулировке, что и Go: URL-значение
определяется по самому значению, а не по имени ключа.
This commit is contained in:
2026-08-27 20:49:38 +05:00
parent b52fac1394
commit a88268b0cd
37 changed files with 1161 additions and 293 deletions
+61 -26
View File
@@ -1,3 +1,26 @@
/**
* Запуск подпроцессов.
*
* API намеренно разделён по namespace'у: read-only и мутирующий. Это не
* стилистика, а часть контракта PHASE 0.
*
* Guard из lib/guard умеет останавливать только то, что через него проходит.
* Пока существовал один универсальный раннер, под которым одинаково жили
* "ss -ltn" и "useradd", инвариант «PHASE 0 ничего не пишет» держался
* исключительно на внимательности автора правки: любой новый вызов с
* мутирующей командой обходил guard молча.
*
* Поэтому здесь нет универсального раннера. Есть два набора:
*
* runReadOnly / runReadOnlySecret
* наблюдение за системой. Guard не трогает — они разрешены в любой фазе.
*
* runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw
* всё, что может изменить хост. Каждый спрашивает разрешения у guard'а.
*
* Выбор набора — сознательное решение на месте вызова, а не умолчание.
*/
import { assertMutationAllowed } from "./guard";
import { info } from "./log";
@@ -20,8 +43,7 @@ function renderCommand(strings: TemplateStringsArray, values: unknown[]): string
return command;
}
export async function run(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
const rendered = renderCommand(command, args);
async function capture(rendered: string, includeCommandInError: boolean): Promise<string> {
const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "pipe",
stderr: "pipe"
@@ -32,14 +54,42 @@ export async function run(command: TemplateStringsArray, ...args: unknown[]): Pr
process.exited
]);
if (exitCode !== 0) {
throw new Error(`command failed (${exitCode}): ${rendered}\n${stderr.trim()}`);
throw new Error(
includeCommandInError
? `command failed (${exitCode}): ${rendered}\n${stderr.trim()}`
: `command failed (${exitCode}): ${stderr.trim()}`
);
}
return stdout.trim();
}
export async function runVisible(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
/**
* Наблюдение за системой: вывод возвращается вызывающему, хост не меняется.
* Команда попадает в текст ошибки — она не содержит секретов по построению.
*/
export async function runReadOnly(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
return capture(renderCommand(command, args), true);
}
/**
* То же, но команда НЕ попадает в текст ошибки: аргументы несут секреты
* (machine token, пароль пира), а сообщение уходит в логи и диагностику.
*/
export async function runReadOnlySecret(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
return capture(renderCommand(command, args), false);
}
/** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */
export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
const rendered = renderCommand(command, args);
assertMutationAllowed(`runVisible(${rendered})`);
assertMutationAllowed(`runMutating(${rendered})`);
return capture(rendered, true);
}
/** Мутация с эхом команды в лог и прямым выводом подпроцесса. */
export async function runMutatingVisible(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
const rendered = renderCommand(command, args);
assertMutationAllowed(`runMutatingVisible(${rendered})`);
info(`running: ${rendered}`);
const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "inherit",
@@ -51,8 +101,9 @@ export async function runVisible(command: TemplateStringsArray, ...args: unknown
}
}
export async function runRawVisible(command: string): Promise<void> {
assertMutationAllowed("runRawVisible(...)");
/** Мутация многострочным скриптом (`sh -eu -c`). */
export async function runMutatingRaw(command: string): Promise<void> {
assertMutationAllowed("runMutatingRaw(...)");
info(`running script:\n${command}`);
const process = Bun.spawn(["sh", "-eu", "-c", command], {
stdout: "inherit",
@@ -64,9 +115,10 @@ export async function runRawVisible(command: string): Promise<void> {
}
}
export async function runHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
/** Мутация без эха команды: аргументы могут содержать секреты. */
export async function runMutatingHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
const rendered = renderCommand(command, args);
assertMutationAllowed(`runHidden(${rendered})`);
assertMutationAllowed(`runMutatingHidden(${rendered})`);
const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "inherit",
stderr: "inherit"
@@ -76,20 +128,3 @@ export async function runHidden(command: TemplateStringsArray, ...args: unknown[
throw new Error(`command failed (${exitCode})`);
}
}
export async function runSecret(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
const rendered = renderCommand(command, args);
const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "pipe",
stderr: "pipe"
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited
]);
if (exitCode !== 0) {
throw new Error(`command failed (${exitCode}): ${stderr.trim()}`);
}
return stdout.trim();
}