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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user