From 2b4a2cb2d56d38d44eded5e5a098731e5d900cfb Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 27 Aug 2026 12:14:47 +0500 Subject: [PATCH] =?UTF-8?q?fix(install):=20=D0=B4=D0=B2=D1=83=D1=85=D1=84?= =?UTF-8?q?=D0=B0=D0=B7=D0=BD=D0=B0=D1=8F=20=D1=83=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0,=20clean-host=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BA=D1=82=20=D0=B8=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Установщик мог повредить работающий сервер до того, как откажется его трогать: install.sh переписывал /usr/local/lib/hy2xs, раскладывал runtime-пакет и перезаписывал install-state.json, и только потом запускал clean-host preflight. При ошибочном запуске поверх старой установки rollback дополнительно делал stop и disable для работающих hysteria-server и hy2xs-admin. Установка разделена на две фазы с жёсткой границей: PHASE 0 - read only: права, checksums пакета, clean-host preflight из распакованного архива (новая команда preflight-install) PHASE 1 - mutation: раскладка оркестратора и сама установка Граница держится не соглашением, а read-only guard: под ним writeText, writeTextAtomic и мутирующие раннеры lib/process кидают ошибку. Внутри install() preflight выполняется раньше первой записи состояния. Остальное в этом же инварианте: - clean-host контракт расширен с двух маркеров до четырнадцати, пути установки и данных берутся из конфигурации, а не захардкожены; - отсутствие HY2XS_CONFIG_SCHEMA_VERSION трактуется как legacy, а не как текущая схема: до v1 этого поля не существовало. Тест, закреплявший прежнее поведение, инвертирован; - install-state несёт идентификацию поколения (product, release_line, config_schema_version); reconfigure и repair проверяют её до всего остального, потому что installed: true мог остаться и от 0.x; - repair требует явного --allow-partial-state; - классификация отказа опирается на ownership-флаги, а не на текст ошибки: раньше сообщение со словом nftables приводило к откату чужого firewall. stop/disable выполняется только для юнитов, развёрнутых текущей операцией, а fatal_pre_apply не делает системного отката и не собирает diagnostics-бандл. --- orchestrator/src/cli.ts | 59 +++- orchestrator/src/commands/install.ts | 256 +++++++++++------- .../src/commands/preflight-install.ts | 43 +++ orchestrator/src/commands/reconfigure.ts | 118 ++++---- orchestrator/src/commands/status.ts | 10 +- orchestrator/src/config/env.ts | 15 +- orchestrator/src/config/profile.ts | 17 ++ orchestrator/src/lib/fs.ts | 3 + orchestrator/src/lib/guard.ts | 44 +++ orchestrator/src/lib/installState.ts | 151 +++++++++++ orchestrator/src/lib/process.ts | 4 + orchestrator/src/steps/cleanHost.ts | 201 ++++++++++++++ orchestrator/src/steps/preflight.ts | 23 +- orchestrator/test/clean-host.test.ts | 108 ++++++++ orchestrator/test/env.test.ts | 25 +- orchestrator/test/install-boundary.test.ts | 112 ++++++++ orchestrator/test/install-state.test.ts | 124 +++++++++ package/install.sh | 54 ++++ 18 files changed, 1188 insertions(+), 179 deletions(-) create mode 100644 orchestrator/src/commands/preflight-install.ts create mode 100644 orchestrator/src/lib/guard.ts create mode 100644 orchestrator/src/lib/installState.ts create mode 100644 orchestrator/src/steps/cleanHost.ts create mode 100644 orchestrator/test/clean-host.test.ts create mode 100644 orchestrator/test/install-boundary.test.ts create mode 100644 orchestrator/test/install-state.test.ts diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index 0423d91..a1a2caa 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -1,4 +1,5 @@ import { install } from "./commands/install"; +import { preflightInstall } from "./commands/preflight-install"; import { reconfigure, repair } from "./commands/reconfigure"; import { doctor } from "./commands/doctor"; import { status } from "./commands/status"; @@ -8,9 +9,10 @@ import type { InstallOptions, ReconfigureOptions } from "./types/context"; function usage(): never { console.error("Usage:"); + console.error(" hy2xs-orchestrator preflight-install --package-dir [--config ]"); console.error(" hy2xs-orchestrator install --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke] [--non-interactive]"); console.error(" hy2xs-orchestrator reconfigure --package-dir [--config ] [--dry-run|--apply] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); - console.error(" hy2xs-orchestrator repair --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); + console.error(" hy2xs-orchestrator repair --package-dir [--config ] [--allow-partial-state] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator doctor --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator status --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); console.error(" hy2xs-orchestrator diagnostics collect --package-dir [--config ] [--skip-firewall] [--skip-service-start] [--skip-smoke]"); @@ -148,6 +150,7 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions { nonInteractive: false, dryRun: false, apply: false, + allowPartialState: false, skipFirewall: false, skipServiceStart: false, skipSmoke: false @@ -164,6 +167,9 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions { options.sourceConfigPath = takeValue(args, i, arg); i += 1; break; + case "--allow-partial-state": + options.allowPartialState = true; + break; case "--dry-run": options.dryRun = true; break; @@ -212,14 +218,63 @@ function parseCommonOptions(args: string[]): InstallOptions { return parseInstallOptions(args); } +/** + * PHASE 0 не принимает ничего, что могло бы повлиять на мутацию: только + * расположение пакета и источник конфигурации. + */ +function parsePreflightInstallOptions(args: string[]): InstallOptions { + const options: InstallOptions = { + packageDir: "", + sourceConfigPath: "", + runtimeConfigPath: "/etc/hy2xs/hy2xs.env", + nonInteractive: true, + skipFirewall: false, + skipServiceStart: false, + skipSmoke: false + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case "--package-dir": + options.packageDir = takeValue(args, i, arg); + i += 1; + break; + case "--config": + options.sourceConfigPath = takeValue(args, i, arg); + i += 1; + break; + default: + console.error(`Unknown argument: ${arg}`); + usage(); + } + } + + if (!options.packageDir) { + console.error("Missing --package-dir"); + usage(); + } + + return options; +} + async function main(): Promise { const [command, ...args] = Bun.argv.slice(2); + if (command === "preflight-install") { + await preflightInstall(parsePreflightInstallOptions(args)); + return; + } if (command === "install") { await install(parseInstallOptions(args)); return; } if (command === "reconfigure") { - await reconfigure(parseReconfigureOptions(args)); + const options = parseReconfigureOptions(args); + if (options.allowPartialState) { + console.error("--allow-partial-state is only valid for `repair`"); + usage(); + } + await reconfigure(options); return; } if (command === "repair") { diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 09ae4ed..87c7add 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -1,8 +1,13 @@ import type { InstallContext, InstallOptions } from "../types/context"; import { fileExists, readText, writeText, writeTextAtomic } from "../lib/fs"; import { runVisible } from "../lib/process"; -import { setOperationContext, step, stepDone } from "../lib/log"; +import { info, setOperationContext, step, stepDone } from "../lib/log"; import { readPackageValue } from "../lib/packageMeta"; +import { + INSTALL_STATE_PATH, + REPAIR_HINT, + buildInstallStateRecord +} from "../lib/installState"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { installDeps } from "../steps/deps"; @@ -16,8 +21,6 @@ import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; import { diagnosticsCollect } from "./diagnostics"; -const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json"; - type InstallPhase = | "installing" | "preflight_ok" @@ -37,26 +40,43 @@ type InstallPhase = | "failed" | "installed"; -type InstallState = { - installed: boolean; - phase: InstallPhase; - version: string; - build_id: string; - op_id: string; - started_at: string; - updated_at: string; - owned_paths: string[]; - last_error: string; - repair_hint?: string; +/** + * Что именно текущая операция успела изменить на сервере. + * + * Rollback обязан опираться на это, а не на текст ошибки: остановить чужой + * работающий сервис только потому, что в сообщении встретилось слово + * "firewall", — недопустимо. + */ +type OperationOwnership = { + stateWritten: boolean; + depsInstalled: boolean; + filesystemPrepared: boolean; + unitsDeployed: boolean; + firewallTouched: boolean; + postInstallWritten: boolean; + servicesStarted: boolean; }; type FailureKind = | "fatal_pre_apply" + | "fatal_post_apply" | "firewall_connectivity_failure" | "service_start_failure" | "smoke_readiness_timeout" | "postinstall_validation_failure"; +function newOwnership(): OperationOwnership { + return { + stateWritten: false, + depsInstalled: false, + filesystemPrepared: false, + unitsDeployed: false, + firewallTouched: false, + postInstallWritten: false, + servicesStarted: false + }; +} + function installOwnedPaths(context: InstallContext): string[] { return [ context.options.runtimeConfigPath, @@ -71,74 +91,87 @@ function installOwnedPaths(context: InstallContext): string[] { ]; } -async function writeInstallState(state: InstallState): Promise { +async function writeInstallState( + context: InstallContext, + phase: InstallPhase, + lastError: string +): Promise { + const record = buildInstallStateRecord({ + productVersion: context.packageVersion, + buildId: context.packageBuildId, + opId: context.installDate, + startedAt: context.installDate, + phase, + installed: phase === "installed", + ownedPaths: installOwnedPaths(context), + lastError, + repairHint: phase === "installed" ? undefined : REPAIR_HINT + }); + await runVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`; - await writeText(INSTALL_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, 0o644); + await writeText(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, 0o644); await runVisible`chown root:root ${INSTALL_STATE_PATH}`; } -async function markInstallSuccessful(context: InstallContext): Promise { - await writeInstallState({ - phase: "installed", - installed: true, - version: context.packageVersion, - build_id: context.packageBuildId, - op_id: context.installDate, - started_at: context.installDate, - updated_at: new Date().toISOString(), - owned_paths: installOwnedPaths(context), - last_error: "" - }); +async function advanceInstallState( + context: InstallContext, + ownership: OperationOwnership, + phase: InstallPhase, + lastError = "" +): Promise { + await writeInstallState(context, phase, lastError); + ownership.stateWritten = true; } -async function advanceInstallState(context: InstallContext, phase: InstallPhase, lastError = ""): Promise { - await writeInstallState({ - phase, - installed: phase === "installed", - version: context.packageVersion, - build_id: context.packageBuildId, - op_id: context.installDate, - started_at: context.installDate, - updated_at: new Date().toISOString(), - owned_paths: installOwnedPaths(context), - last_error: lastError, - repair_hint: phase === "installed" ? undefined : "run: hy2xs-orchestrator repair --package-dir --config /etc/hy2xs/hy2xs.env" - }); -} - -function classifyFailure(phase: InstallPhase, message: string): FailureKind { - const m = message.toLowerCase(); - if (phase === "firewall_applied" || m.includes("nft") || m.includes("firewall") || m.includes("ssh port check failed")) { +/** + * Классификация опирается на то, что операция реально успела применить. + * `fatal_pre_apply` по определению означает «на сервере ничего не изменено». + */ +export function classifyFailure(ownership: OperationOwnership, phase: InstallPhase): FailureKind { + if (ownership.servicesStarted) { + return phase === "smoke_running" || phase === "smoke_failed" + ? "smoke_readiness_timeout" + : "service_start_failure"; + } + if (ownership.postInstallWritten) { + return "postinstall_validation_failure"; + } + if (ownership.firewallTouched) { return "firewall_connectivity_failure"; } - if (phase === "services_started" || m.includes("is not active")) { - return "service_start_failure"; - } - if (phase === "smoke_failed" || m.includes("not ready") || m.includes("timeout") || m.includes("listener")) { - return "smoke_readiness_timeout"; - } - if (phase === "postinstall_env_written" || phase === "bootstrap_secret_written" || m.includes("permission") || m.includes("unexpected")) { - return "postinstall_validation_failure"; + if (ownership.unitsDeployed || ownership.filesystemPrepared || ownership.depsInstalled) { + return "fatal_post_apply"; } return "fatal_pre_apply"; } +/** + * Инвариант: сервисы останавливаются и выключаются ТОЛЬКО если их развернула + * текущая операция. Иначе неудачный запуск установщика на чужом сервере + * положил бы работающий сервис. + */ async function rollbackFailedInstall( context: InstallContext, - state: { firewallTouched: boolean }, - failureKind: FailureKind, + ownership: OperationOwnership, + failureKind: FailureKind ): Promise { - if (failureKind === "firewall_connectivity_failure" || failureKind === "postinstall_validation_failure") { - if (state.firewallTouched) { - await rollbackFirewallNow(context); - } + if (failureKind === "fatal_pre_apply") { + info("pre-apply failure: nothing was applied, system rollback is not required"); + return; } - if (failureKind === "fatal_pre_apply" || failureKind === "firewall_connectivity_failure" || failureKind === "postinstall_validation_failure") { - await runVisible`systemctl stop hysteria-server hy2xs-admin || true`; - await runVisible`systemctl disable hysteria-server hy2xs-admin || true`; - await runVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; + if (ownership.firewallTouched) { + await rollbackFirewallNow(context); } + + if (!ownership.unitsDeployed) { + info("rollback: systemd units were not deployed by this operation, leaving services untouched"); + return; + } + + await runVisible`systemctl stop hysteria-server hy2xs-admin || true`; + await runVisible`systemctl disable hysteria-server hy2xs-admin || true`; + await runVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; } export async function install(options: InstallOptions): Promise { @@ -169,32 +202,33 @@ export async function install(options: InstallOptions): Promise { throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256"); } - const state = { - firewallTouched: false, - lastPhase: "installing" as InstallPhase - }; + const ownership = newOwnership(); + let phase: InstallPhase = "installing"; try { - await advanceInstallState(context, "installing"); - state.lastPhase = "installing"; + // PHASE 1 начинается только после успешного preflight: до него install + // не пишет ни одного persistent path, включая install-state. step("preflight"); - await preflight(context, { requireCapabilities: false }); + await preflight(context, { requireCapabilities: false, cleanHostPhase: "install" }); stepDone("preflight"); - await advanceInstallState(context, "preflight_ok"); - state.lastPhase = "preflight_ok"; + + await advanceInstallState(context, ownership, "preflight_ok"); + phase = "preflight_ok"; step("system dependencies"); await installDeps(context); + ownership.depsInstalled = true; stepDone("system dependencies"); step("preflight capabilities"); - await preflight(context, { requireCapabilities: true }); + await preflight(context, { requireCapabilities: true, cleanHostPhase: "install" }); stepDone("preflight capabilities"); - await advanceInstallState(context, "deps_ok"); - state.lastPhase = "deps_ok"; + await advanceInstallState(context, ownership, "deps_ok"); + phase = "deps_ok"; step("filesystem"); await prepareFilesystem(context); + ownership.filesystemPrepared = true; stepDone("filesystem"); - await advanceInstallState(context, "filesystem_ready"); - state.lastPhase = "filesystem_ready"; + await advanceInstallState(context, ownership, "filesystem_ready"); + phase = "filesystem_ready"; step("write runtime env"); await runVisible`mkdir -p /etc/hy2xs`; await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), { @@ -203,63 +237,79 @@ export async function install(options: InstallOptions): Promise { group: "root" }); stepDone("write runtime env"); - await advanceInstallState(context, "runtime_env_written"); - state.lastPhase = "runtime_env_written"; + await advanceInstallState(context, ownership, "runtime_env_written"); + phase = "runtime_env_written"; step("bundled UI"); await deployUi(context); stepDone("bundled UI"); - await advanceInstallState(context, "ui_deployed"); - state.lastPhase = "ui_deployed"; + await advanceInstallState(context, ownership, "ui_deployed"); + phase = "ui_deployed"; step("Hysteria2 upstream install"); await installHysteria(context); stepDone("Hysteria2 upstream install"); - await advanceInstallState(context, "hysteria_installed"); - state.lastPhase = "hysteria_installed"; + await advanceInstallState(context, ownership, "hysteria_installed"); + phase = "hysteria_installed"; step("config generation"); await generateConfig(context); stepDone("config generation"); - await advanceInstallState(context, "config_generated"); - state.lastPhase = "config_generated"; + await advanceInstallState(context, ownership, "config_generated"); + phase = "config_generated"; step("systemd units"); await deploySystemd(context); + ownership.unitsDeployed = true; stepDone("systemd units"); - await advanceInstallState(context, "units_deployed"); - state.lastPhase = "units_deployed"; + await advanceInstallState(context, ownership, "units_deployed"); + phase = "units_deployed"; step("firewall"); - state.firewallTouched = true; + ownership.firewallTouched = true; await applyFirewall(context); stepDone("firewall"); - await advanceInstallState(context, "firewall_applied"); - state.lastPhase = "firewall_applied"; + await advanceInstallState(context, ownership, "firewall_applied"); + phase = "firewall_applied"; step("post-install env"); await writePostInstallEnv(context); + ownership.postInstallWritten = true; stepDone("post-install env"); - await advanceInstallState(context, "postinstall_env_written"); - state.lastPhase = "postinstall_env_written"; + await advanceInstallState(context, ownership, "postinstall_env_written"); + phase = "postinstall_env_written"; step("bootstrap admin secret"); await ensureBootstrapAdminSecret(context); stepDone("bootstrap admin secret"); - await advanceInstallState(context, "bootstrap_secret_written"); - state.lastPhase = "bootstrap_secret_written"; + await advanceInstallState(context, ownership, "bootstrap_secret_written"); + phase = "bootstrap_secret_written"; step("smoke checks"); - await advanceInstallState(context, "services_started"); - state.lastPhase = "services_started"; - await advanceInstallState(context, "smoke_running"); - state.lastPhase = "smoke_running"; + ownership.servicesStarted = true; + await advanceInstallState(context, ownership, "services_started"); + phase = "services_started"; + await advanceInstallState(context, ownership, "smoke_running"); + phase = "smoke_running"; await smoke(context); stepDone("smoke checks"); step("finalize firewall rollback guard"); await cancelFirewallRollback(context); stepDone("finalize firewall rollback guard"); step("mark install successful"); - await markInstallSuccessful(context); + await advanceInstallState(context, ownership, "installed"); stepDone("mark install successful"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const failureKind = classifyFailure(state.lastPhase, message); - await advanceInstallState(context, failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", `${failureKind}: ${message}`); + const failureKind = classifyFailure(ownership, phase); + + if (failureKind === "fatal_pre_apply") { + // Хост не изменён: не пишем install-state и не собираем bundle в + // /var/log/hy2xs — сбор диагностики сам создал бы каталоги. + info(`install refused before applying anything: ${failureKind}`); + throw error; + } + + await advanceInstallState( + context, + ownership, + failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", + `${failureKind}: ${message}` + ); await diagnosticsCollect(options); - await rollbackFailedInstall(context, state, failureKind); + await rollbackFailedInstall(context, ownership, failureKind); throw error; } } diff --git a/orchestrator/src/commands/preflight-install.ts b/orchestrator/src/commands/preflight-install.ts new file mode 100644 index 0000000..0a9d267 --- /dev/null +++ b/orchestrator/src/commands/preflight-install.ts @@ -0,0 +1,43 @@ +import type { InstallOptions, RuntimeContext } from "../types/context"; +import { fileExists, readText } from "../lib/fs"; +import { enableReadOnlyGuard } from "../lib/guard"; +import { info, setOperationContext, step, stepDone } from "../lib/log"; +import { readPackageValue } from "../lib/packageMeta"; +import { parseRuntimeEnv } from "../config/env"; +import { preflight } from "../steps/preflight"; + +/** + * PHASE 0 установки: полностью read-only проверка целевого сервера. + * + * Запускается install.sh из РАСПАКОВАННОГО пакета до того, как на сервере + * будет изменён хотя бы один persistent path — включая /usr/local/lib/hy2xs. + * Единственный результат — код возврата. + */ +export async function preflightInstall(options: InstallOptions): Promise { + setOperationContext(`preflight-install-${Date.now().toString(36)}`); + enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); + + const hasSourceConfig = options.sourceConfigPath ? await fileExists(options.sourceConfigPath) : false; + if (options.sourceConfigPath && !hasSourceConfig) { + throw new Error(`config source not found: ${options.sourceConfigPath}`); + } + const sourceConfigPath = hasSourceConfig ? options.sourceConfigPath : `${options.packageDir}/config/hy2xs.env`; + const config = parseRuntimeEnv(await readText(sourceConfigPath)); + + const context: RuntimeContext = { + mode: "install", + options, + config, + packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), + packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), + installDate: new Date().toISOString(), + hysteriaVersion: "unknown", + hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown") + }; + + step("bootstrap preflight (read-only)"); + await preflight(context, { requireCapabilities: false, cleanHostPhase: "bootstrap" }); + stepDone("bootstrap preflight (read-only)"); + + info("clean-host contract satisfied: no persistent path was modified"); +} diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index 9f6221c..bbe4280 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -1,6 +1,13 @@ import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; -import { fileExists, readText, writeText } from "../lib/fs"; +import { fileExists, readText, writeTextAtomic } from "../lib/fs"; import { info, setOperationContext, step, stepDone } from "../lib/log"; +import { + INSTALL_STATE_PATH, + REPAIR_HINT, + assertCurrentGeneration, + buildInstallStateRecord, + type InstallStateRecord +} from "../lib/installState"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { generateConfig } from "../steps/config"; @@ -12,21 +19,6 @@ import { runVisible } from "../lib/process"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; import { diagnosticsCollect } from "./diagnostics"; -const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json"; - -type InstallState = { - installed: boolean; - phase: string; - version?: string; - build_id?: string; - op_id?: string; - started_at?: string; - updated_at?: string; - owned_paths?: string[]; - last_error?: string; - repair_hint?: string; -}; - type ReconfigurePhase = | "reconfiguring" | "repairing" @@ -57,23 +49,23 @@ function ownedPaths(context: ReconfigureContext): string[] { ]; } -async function writeInstallState(state: InstallState): Promise { - await writeText(INSTALL_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, 0o644); - await runVisible`chown root:root ${INSTALL_STATE_PATH}`; -} - async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, lastError = ""): Promise { - await writeInstallState({ - installed: phase === "installed", + const record = buildInstallStateRecord({ + productVersion: context.packageVersion, + buildId: context.packageBuildId, + opId: operationKey(context), + startedAt: context.installDate, phase, - version: context.packageVersion, - build_id: context.packageBuildId, - op_id: operationKey(context), - started_at: context.installDate, - updated_at: new Date().toISOString(), - owned_paths: ownedPaths(context), - last_error: lastError, - repair_hint: phase === "installed" ? "" : "run: hy2xs-orchestrator repair --package-dir --config /etc/hy2xs/hy2xs.env" + installed: phase === "installed", + ownedPaths: ownedPaths(context), + lastError, + repairHint: phase === "installed" ? undefined : REPAIR_HINT + }); + + await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, { + mode: 0o644, + owner: "root", + group: "root" }); } @@ -108,39 +100,34 @@ async function rollbackCurrentState(): Promise { await runVisible`systemctl restart hysteria-server hy2xs-admin || true`; } -async function ensureInstallStateExists(): Promise { - if (!(await fileExists(INSTALL_STATE_PATH))) { - throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`); - } - - const raw = await readText(INSTALL_STATE_PATH); - let parsed: InstallState; - try { - parsed = JSON.parse(raw) as InstallState; - } catch { - throw new Error(`invalid install state marker format: ${INSTALL_STATE_PATH}`); - } - if (!parsed.installed) { - throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`); - } -} - -async function readInstallState(): Promise { +async function readInstallState(): Promise { if (!(await fileExists(INSTALL_STATE_PATH))) { return null; } try { - return JSON.parse(await readText(INSTALL_STATE_PATH)) as InstallState; + return JSON.parse(await readText(INSTALL_STATE_PATH)) as InstallStateRecord; } catch { return null; } } +/** + * reconfigure/repair работают только поверх установки ТЕКУЩЕГО поколения. + * + * Наличие `installed: true` ничего не доказывает: такой же маркер мог остаться + * от 0.x. Поэтому сначала проверяется поколение, и только потом — полнота. + */ async function ensureInstallStateForOperation(options: ReconfigureOptions): Promise { const state = await readInstallState(); if (!state) { - throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`); + throw new Error( + `install state marker is missing: ${INSTALL_STATE_PATH}. ` + + "HY2XS v1 требует чистой установки; см. docs/14-legacy-cleanup.md" + ); } + + assertCurrentGeneration(state); + if (state.installed) { return; } @@ -148,7 +135,10 @@ async function ensureInstallStateForOperation(options: ReconfigureOptions): Prom info(`repair mode: proceeding with partial install state (phase=${state.phase ?? "unknown"})`); return; } - throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`); + throw new Error( + `install state marker does not indicate successful installation (phase=${state.phase ?? "unknown"}): ` + + `${INSTALL_STATE_PATH}. Для незавершённой установки используйте: repair --allow-partial-state` + ); } async function warnBootstrapDrift(nextConfigRaw: string): Promise { @@ -180,12 +170,14 @@ export async function reconfigure(options: ReconfigureOptions): Promise { hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown") }; - step("preflight"); - await preflight(context); - stepDone("preflight"); + // Поколение проверяется до всего остального: если маркер чужой, дальнейшие + // проверки конфигурации не имеют смысла. step("install state marker"); await ensureInstallStateForOperation(options); stepDone("install state marker"); + step("preflight"); + await preflight(context); + stepDone("preflight"); await warnBootstrapDrift(configRaw); if (options.dryRun) { @@ -217,9 +209,11 @@ export async function reconfigure(options: ReconfigureOptions): Promise { stepDone("firewall"); await markPhase(context, "firewall_applied"); step("write env artifacts"); - await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); - await runVisible`chown root:root ${options.runtimeConfigPath}`; - await runVisible`chmod 0600 ${options.runtimeConfigPath}`; + await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), { + mode: 0o600, + owner: "root", + group: "root" + }); await ensureBootstrapAdminSecret(context); await writePostInstallEnv(context); stepDone("write env artifacts"); @@ -251,12 +245,18 @@ export async function reconfigure(options: ReconfigureOptions): Promise { } } +/** + * repair — это тот же apply-проход, но с явным разрешением работать поверх + * незавершённой установки. Разрешение приходит флагом `--allow-partial-state`, + * а не подразумевается: молчаливое согласие на произвольный partial marker и + * было тем, что позволяло чинить «установку» чужого поколения. + */ export async function repair(options: ReconfigureOptions): Promise { const effective: ReconfigureOptions = { ...options, dryRun: false, apply: true, - allowPartialState: true, + allowPartialState: options.allowPartialState ?? false, skipSmoke: options.skipSmoke ?? false, skipServiceStart: options.skipServiceStart ?? false }; diff --git a/orchestrator/src/commands/status.ts b/orchestrator/src/commands/status.ts index 89f7064..159f50b 100644 --- a/orchestrator/src/commands/status.ts +++ b/orchestrator/src/commands/status.ts @@ -4,8 +4,7 @@ import { info, setOperationContext } from "../lib/log"; import { run } from "../lib/process"; import { getPlatformProfile } from "../platform/profile"; import { detectFirewallEntrypointKind } from "../steps/firewall"; - -const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json"; +import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState"; async function unitState(unit: string): Promise { try { @@ -61,6 +60,9 @@ export async function status(_options: CommonOptions): Promise { ? "installed" : (installPhase === "unknown" ? "failed" : installPhase); const rollbackGuardActive = rollbackGuardUnits.length > 0; + // Отдельное поле: маркер может присутствовать и быть «installed», но + // принадлежать другому поколению продукта. + const generationProblems = installState ? detectGenerationProblems(installState) : []; const runtimeState = (hysteriaService === "active" && adminService === "active") ? (installStateEffective === "installed" ? "running" : "partial") : "stopped"; @@ -82,6 +84,10 @@ export async function status(_options: CommonOptions): Promise { tls: await tlsState(), install_state_present: await fileExists(INSTALL_STATE_PATH), install_state: installState, + install_state_generation: installState + ? (generationProblems.length === 0 ? "current" : "foreign") + : "absent", + install_state_generation_problems: generationProblems, rollback_guard_active: rollbackGuardActive, rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [], runtime_state: runtimeState, diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index a259fa6..207576e 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -150,10 +150,23 @@ function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" { return "http"; } +/** + * Версия схемы конфигурации — обязательное поле. + * + * Отсутствие маркера НЕ означает «текущая схема»: до HY2XS v1 этого поля не + * существовало вовсе, поэтому именно пустое значение — самый вероятный признак + * конфигурации 0.x. Любой fallback здесь молча превращал бы legacy-конфиг в + * якобы валидный. + */ function normalizeConfigSchemaVersion(value: string | undefined): number { const raw = (value ?? "").trim(); if (!raw) { - return HY2XS_CONFIG_SCHEMA_VERSION; + throw new Error( + "HY2XS_CONFIG_SCHEMA_VERSION отсутствует в конфигурации.\n" + + "Похоже на конфигурацию предыдущего поколения (0.x) или на неизвестный формат.\n" + + `HY2XS v1 понимает только схему ${HY2XS_CONFIG_SCHEMA_VERSION} и не выполняет миграцию на месте.\n` + + "Очистите старую установку и установите HY2XS v1 с нуля: см. docs/14-legacy-cleanup.md" + ); } const parsed = Number(raw); if (!Number.isInteger(parsed) || parsed < 1) { diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts index d5f8a45..d259896 100644 --- a/orchestrator/src/config/profile.ts +++ b/orchestrator/src/config/profile.ts @@ -8,6 +8,23 @@ import type { HysteriaObfsType, RuntimeConfig } from "../types/context"; export const HY2XS_CONFIG_SCHEMA_VERSION = 2; +/** + * Линия релиза продукта. Меняется только при смене поколения, внутри которого + * установка остаётся совместимой сама с собой. Используется install-state, + * чтобы reconfigure/repair не работали поверх чужого поколения. + * + * Значение синхронизировано с HY2XS_RELEASE_LINE в корневом versions.env + * (проверяется шагом verify_versions_contract на сборке). + */ +export const HY2XS_RELEASE_LINE = 1; + +/** + * Целевая платформа production-профиля. Значения синхронизированы с + * HY2XS_TARGET_OS_VERSION / HY2XS_TARGET_ARCH в корневом versions.env. + */ +export const HY2XS_TARGET_DEBIAN_VERSION = 13; +export const HY2XS_TARGET_ARCH = "amd64"; + export const HYSTERIA_OBFS_TYPES: readonly HysteriaObfsType[] = ["gecko", "salamander"]; /** Тип обфускации для новой установки. Salamander остаётся compatibility fallback. */ diff --git a/orchestrator/src/lib/fs.ts b/orchestrator/src/lib/fs.ts index b6b2515..3384f00 100644 --- a/orchestrator/src/lib/fs.ts +++ b/orchestrator/src/lib/fs.ts @@ -1,4 +1,5 @@ import { stat } from "node:fs/promises"; +import { assertMutationAllowed } from "./guard"; async function statSafe(path: string): Promise { try { @@ -30,6 +31,7 @@ export async function readText(path: string): Promise { } export async function writeText(path: string, data: string, mode?: number): Promise { + assertMutationAllowed(`writeText(${path})`); await Bun.write(path, data); if (mode !== undefined) { const result = Bun.spawnSync(["chmod", mode.toString(8), path], { @@ -51,6 +53,7 @@ export async function writeTextAtomic( group: string; } ): Promise { + assertMutationAllowed(`writeTextAtomic(${path})`); const dir = path.replace(/\/[^/]+$/, "") || "."; const base = path.split("/").pop() || "tmp"; const tmp = `${dir}/.${base}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`; diff --git a/orchestrator/src/lib/guard.ts b/orchestrator/src/lib/guard.ts new file mode 100644 index 0000000..3f2de65 --- /dev/null +++ b/orchestrator/src/lib/guard.ts @@ -0,0 +1,44 @@ +/** + * Read-only guard для bootstrap-фазы установки. + * + * Контракт установки HY2XS состоит из двух фаз: + * + * PHASE 0 — read only: проверка чистоты хоста. Ни одной записи в persistent + * path, даже в /usr/local/lib/hy2xs. + * PHASE 1 — mutation: только после успешной PHASE 0. + * + * Граница между фазами держится не соглашением, а этим guard'ом: под ним + * любая запись через lib/fs и любой мутирующий раннер lib/process кидают + * ошибку. Без него «PHASE 0 ничего не пишет» снова станет неправдой при + * первой же правке. + */ + +let active = false; +let reason = ""; + +export function enableReadOnlyGuard(guardReason: string): void { + active = true; + reason = guardReason; +} + +export function disableReadOnlyGuard(): void { + active = false; + reason = ""; +} + +export function isReadOnlyGuardActive(): boolean { + return active; +} + +export function readOnlyGuardReason(): string { + return reason; +} + +export function assertMutationAllowed(operation: string): void { + if (!active) { + return; + } + throw new Error( + `read-only guard violation: ${operation} is not allowed during ${reason || "the read-only phase"}` + ); +} diff --git a/orchestrator/src/lib/installState.ts b/orchestrator/src/lib/installState.ts new file mode 100644 index 0000000..39a09ad --- /dev/null +++ b/orchestrator/src/lib/installState.ts @@ -0,0 +1,151 @@ +import { HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE } from "../config/profile"; + +/** + * Маркер установки HY2XS. + * + * Файл отвечает на один вопрос: «эта машина — установка ТЕКУЩЕГО поколения + * HY2XS, и в каком она состоянии». Поэтому кроме фазы он обязан нести + * идентификацию поколения: product/release_line/config_schema_version. + * Без них reconfigure/repair не отличают v1 от произвольного старого маркера. + */ + +export const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json"; + +export const HY2XS_PRODUCT_ID = "hy2xs"; + +export type InstallStateRecord = { + product: string; + release_line: number; + config_schema_version: number; + product_version: string; + installed: boolean; + phase: string; + build_id: string; + op_id: string; + started_at: string; + updated_at: string; + owned_paths: string[]; + last_error: string; + repair_hint?: string; +}; + +export type InstallStateGenerationProblem = + | "missing_product" + | "foreign_product" + | "missing_release_line" + | "foreign_release_line" + | "missing_config_schema" + | "foreign_config_schema"; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +/** + * Возвращает список расхождений поколения. Пустой список означает, что маркер + * принадлежит текущему поколению HY2XS. + */ +export function detectGenerationProblems(state: unknown): InstallStateGenerationProblem[] { + const record = asRecord(state); + if (!record) { + return ["missing_product", "missing_release_line", "missing_config_schema"]; + } + + const problems: InstallStateGenerationProblem[] = []; + + if (typeof record.product !== "string" || record.product.trim() === "") { + problems.push("missing_product"); + } else if (record.product !== HY2XS_PRODUCT_ID) { + problems.push("foreign_product"); + } + + if (typeof record.release_line !== "number" || !Number.isInteger(record.release_line)) { + problems.push("missing_release_line"); + } else if (record.release_line !== HY2XS_RELEASE_LINE) { + problems.push("foreign_release_line"); + } + + if (typeof record.config_schema_version !== "number" || !Number.isInteger(record.config_schema_version)) { + problems.push("missing_config_schema"); + } else if (record.config_schema_version !== HY2XS_CONFIG_SCHEMA_VERSION) { + problems.push("foreign_config_schema"); + } + + return problems; +} + +export function renderGenerationFailure( + problems: readonly InstallStateGenerationProblem[], + state: unknown +): string { + const record = asRecord(state) ?? {}; + const detail = problems + .map((problem) => { + switch (problem) { + case "missing_product": + return " - в маркере нет поля product"; + case "foreign_product": + return ` - product=${JSON.stringify(record.product)}, ожидается ${JSON.stringify(HY2XS_PRODUCT_ID)}`; + case "missing_release_line": + return " - в маркере нет поля release_line"; + case "foreign_release_line": + return ` - release_line=${JSON.stringify(record.release_line)}, ожидается ${HY2XS_RELEASE_LINE}`; + case "missing_config_schema": + return " - в маркере нет поля config_schema_version"; + case "foreign_config_schema": + return ` - config_schema_version=${JSON.stringify(record.config_schema_version)}, ожидается ${HY2XS_CONFIG_SCHEMA_VERSION}`; + } + }) + .join("\n"); + + return [ + `Маркер установки ${INSTALL_STATE_PATH} не относится к текущему поколению HY2XS.`, + "HY2XS v1 не мигрирует состояние предыдущих версий.", + "", + "Расхождения:", + detail, + "", + "Требуется чистая переустановка: см. docs/14-legacy-cleanup.md" + ].join("\n"); +} + +export function assertCurrentGeneration(state: unknown): void { + const problems = detectGenerationProblems(state); + if (problems.length > 0) { + throw new Error(renderGenerationFailure(problems, state)); + } +} + +export function buildInstallStateRecord(input: { + productVersion: string; + buildId: string; + opId: string; + startedAt: string; + phase: string; + installed: boolean; + ownedPaths: string[]; + lastError?: string; + repairHint?: string; +}): InstallStateRecord { + return { + product: HY2XS_PRODUCT_ID, + release_line: HY2XS_RELEASE_LINE, + config_schema_version: HY2XS_CONFIG_SCHEMA_VERSION, + product_version: input.productVersion, + installed: input.installed, + phase: input.phase, + build_id: input.buildId, + op_id: input.opId, + started_at: input.startedAt, + updated_at: new Date().toISOString(), + owned_paths: input.ownedPaths, + last_error: input.lastError ?? "", + ...(input.repairHint ? { repair_hint: input.repairHint } : {}) + }; +} + +export const REPAIR_HINT = + "run: hy2xs-orchestrator repair --package-dir --config /etc/hy2xs/hy2xs.env --allow-partial-state"; diff --git a/orchestrator/src/lib/process.ts b/orchestrator/src/lib/process.ts index 43b69af..b7297f7 100644 --- a/orchestrator/src/lib/process.ts +++ b/orchestrator/src/lib/process.ts @@ -1,3 +1,4 @@ +import { assertMutationAllowed } from "./guard"; import { info } from "./log"; function shellQuote(value: unknown): string { @@ -38,6 +39,7 @@ export async function run(command: TemplateStringsArray, ...args: unknown[]): Pr export async function runVisible(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); + assertMutationAllowed(`runVisible(${rendered})`); info(`running: ${rendered}`); const process = Bun.spawn(["sh", "-c", rendered], { stdout: "inherit", @@ -50,6 +52,7 @@ export async function runVisible(command: TemplateStringsArray, ...args: unknown } export async function runRawVisible(command: string): Promise { + assertMutationAllowed("runRawVisible(...)"); info(`running script:\n${command}`); const process = Bun.spawn(["sh", "-eu", "-c", command], { stdout: "inherit", @@ -63,6 +66,7 @@ export async function runRawVisible(command: string): Promise { export async function runHidden(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); + assertMutationAllowed(`runHidden(${rendered})`); const process = Bun.spawn(["sh", "-c", rendered], { stdout: "inherit", stderr: "inherit" diff --git a/orchestrator/src/steps/cleanHost.ts b/orchestrator/src/steps/cleanHost.ts new file mode 100644 index 0000000..f2d84d5 --- /dev/null +++ b/orchestrator/src/steps/cleanHost.ts @@ -0,0 +1,201 @@ +import type { RuntimeConfig } from "../types/context"; +import { dirExists, fileExists } from "../lib/fs"; +import { run } from "../lib/process"; + +/** + * Контракт чистого хоста для HY2XS v1. + * + * HY2XS v1 не мигрирует состояние 0.x и не устанавливается поверх другого + * поколения. Поэтому список маркеров ниже — это не эвристика «на всякий + * случай», а формальная граница продукта: пока хотя бы один маркер найден, + * установка не имеет права изменить на сервере ничего. + * + * Список намеренно шире, чем «post-install.env + /opt/hy2xs-admin»: установка + * поверх сервера, где остались только systemd-юниты или база админки, тоже + * должна быть остановлена. + */ + +export type LegacyMarkerKind = "file" | "dir" | "unit"; + +export type LegacyMarker = { + kind: LegacyMarkerKind; + target: string; + /** Человекочитаемое объяснение на русском: попадает в текст отказа. */ + description: string; + /** + * Путь, который создаёт сам install.sh в PHASE 1 до запуска оркестратора. + * В PHASE 0 он остаётся признаком чужой установки, в PHASE 1 — уже наш. + */ + createdByInstaller?: true; +}; + +/** + * PHASE 0 проверяет полный список. PHASE 1 (уже внутри `install`) исключает + * пути, которые install.sh законно создал между фазами. + */ +export type CleanHostPhase = "bootstrap" | "install"; + +export type HostProbe = { + fileExists(path: string): Promise; + dirExists(path: string): Promise; + unitExists(unit: string): Promise; +}; + +/** + * Собирает список маркеров с учётом путей из конфигурации: installDir/dataDir + * переопределяемы, и захардкоженный список пропустил бы нестандартную установку. + */ +export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = "bootstrap"): LegacyMarker[] { + const markers: LegacyMarker[] = [ + { + kind: "file", + target: "/etc/hysteria/post-install.env", + description: "post-install.env предыдущей установки HY2XS" + }, + { + kind: "file", + target: "/etc/hy2xs/hy2xs.env", + description: "runtime-конфигурация предыдущей установки HY2XS" + }, + { + kind: "file", + target: "/var/lib/hy2xs/install-state.json", + description: "маркер состояния установки HY2XS" + }, + { + kind: "file", + target: "/etc/hy2xs/bootstrap-admin.secret", + description: "bootstrap-секрет администратора предыдущей установки" + }, + { + kind: "dir", + target: "/usr/local/lib/hy2xs/package", + description: "runtime-пакет предыдущей установки HY2XS", + createdByInstaller: true + }, + { + kind: "file", + target: "/etc/hysteria/config.yaml", + description: "сгенерированный серверный конфиг Hysteria" + }, + { + kind: "file", + target: "/usr/local/bin/hysteria", + description: "уже установленный бинарник Hysteria" + }, + { + kind: "file", + target: "/etc/nftables.d/hy2xs.nft", + description: "nftables-фрагмент HY2XS" + }, + { + kind: "unit", + target: "hy2xs-admin.service", + description: "systemd-юнит админки HY2XS" + }, + { + kind: "unit", + target: "hysteria-server.service", + description: "systemd-юнит сервера Hysteria" + }, + // Наследие H UI / HY2XS 0.x. + { + kind: "unit", + target: "h-ui.service", + description: "systemd-юнит панели предыдущего поколения (0.x)" + }, + { + kind: "dir", + target: "/usr/local/h-ui", + description: "каталог панели предыдущего поколения (0.x)" + } + ]; + + markers.push({ + kind: "dir", + target: config.installDir, + description: "каталог приложения админки HY2XS" + }); + markers.push({ + kind: "dir", + target: config.dataDir, + description: "каталог данных админки HY2XS (включая базу)" + }); + + const seen = new Set(); + return markers.filter((marker) => { + if (phase === "install" && marker.createdByInstaller) { + return false; + } + const key = `${marker.kind}:${marker.target}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +export const defaultHostProbe: HostProbe = { + fileExists, + dirExists, + async unitExists(unit: string): Promise { + if (await fileExists(`/etc/systemd/system/${unit}`)) { + return true; + } + try { + const listed = await run`systemctl list-unit-files --no-legend ${unit} 2>/dev/null || true`; + return listed.trim().length > 0; + } catch { + return false; + } + } +}; + +export async function detectLegacyMarkers( + markers: readonly LegacyMarker[], + probe: HostProbe +): Promise { + const found: LegacyMarker[] = []; + for (const marker of markers) { + const present = marker.kind === "file" + ? await probe.fileExists(marker.target) + : marker.kind === "dir" + ? await probe.dirExists(marker.target) + : await probe.unitExists(marker.target); + if (present) { + found.push(marker); + } + } + return found; +} + +export function renderLegacyFailure(found: readonly LegacyMarker[]): string { + const list = found.map((marker) => ` - ${marker.target} (${marker.description})`).join("\n"); + return [ + "На сервере обнаружена предыдущая или посторонняя установка.", + "HY2XS v1 не поддерживает установку поверх и не мигрирует состояние 0.x.", + "Ни один файл на сервере не изменён.", + "", + "Найденные маркеры:", + list, + "", + "Очистите сервер и установите HY2XS заново: см. docs/14-legacy-cleanup.md", + "или запустите tools/legacy/purge-v0.sh из репозитория." + ].join("\n"); +} + +/** + * Read-only проверка. Вызывается в PHASE 0 (bootstrap preflight) и повторно + * внутри install перед первой записью состояния. + */ +export async function assertCleanHost( + config: RuntimeConfig, + phase: CleanHostPhase = "bootstrap", + probe: HostProbe = defaultHostProbe +): Promise { + const found = await detectLegacyMarkers(legacyMarkersFor(config, phase), probe); + if (found.length > 0) { + throw new Error(renderLegacyFailure(found)); + } +} diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts index 5d3eedf..0d92878 100644 --- a/orchestrator/src/steps/preflight.ts +++ b/orchestrator/src/steps/preflight.ts @@ -1,12 +1,19 @@ import type { RuntimeContext } from "../types/context"; import { resolve4, resolve6 } from "node:dns/promises"; -import { dirExists, fileExists } from "../lib/fs"; +import { fileExists } from "../lib/fs"; import { fail, info } from "../lib/log"; import { run } from "../lib/process"; import { assertPlatform } from "../platform/assert"; +import { HY2XS_TARGET_ARCH, HY2XS_TARGET_DEBIAN_VERSION } from "../config/profile"; +import { assertCleanHost, type CleanHostPhase } from "./cleanHost"; type PreflightOptions = { requireCapabilities?: boolean; + /** + * bootstrap — PHASE 0, до любой мутации (полный список маркеров). + * install — PHASE 1, install.sh уже разложил runtime-пакет. + */ + cleanHostPhase?: CleanHostPhase; }; function isNoDnsRecords(error: unknown): boolean { @@ -59,8 +66,8 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti await assertPlatform({ distro: "debian", - supportedVersions: [13], - architectures: ["amd64"], + supportedVersions: [HY2XS_TARGET_DEBIAN_VERSION], + architectures: [HY2XS_TARGET_ARCH], requireSystemdRun: requireCapabilities, requireNftables: requireCapabilities && needsFirewallCapabilities, requireOpenSsl3: requireCapabilities @@ -95,12 +102,10 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti } } - if (!isReconfigure && (await fileExists("/etc/hysteria/post-install.env"))) { - fail("existing HY2XS post-install.env found; update/repair is out of scope"); - } - - if (!isReconfigure && (await dirExists(context.config.installDir))) { - fail("existing /opt/hy2xs-admin found; conflicting old state"); + // Полный clean-host контракт: HY2XS v1 не устанавливается поверх другого + // поколения. Проверка read-only и выполняется до любой мутации. + if (!isReconfigure) { + await assertCleanHost(context.config, options?.cleanHostPhase ?? "install"); } const ports = new Set([context.config.hysteriaPort, context.config.uiPort]); diff --git a/orchestrator/test/clean-host.test.ts b/orchestrator/test/clean-host.test.ts new file mode 100644 index 0000000..103ffdc --- /dev/null +++ b/orchestrator/test/clean-host.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { + assertCleanHost, + detectLegacyMarkers, + legacyMarkersFor, + renderLegacyFailure, + type HostProbe +} from "../src/steps/cleanHost"; +import { baselineConfig } from "./fixtures"; + +/** + * Проба, которая считает «существующими» ровно переданный набор целей. + * Это позволяет проверить контракт чистого хоста без файловой системы. + */ +function probeWith(present: readonly string[]): HostProbe { + const set = new Set(present); + return { + async fileExists(path) { + return set.has(path); + }, + async dirExists(path) { + return set.has(path); + }, + async unitExists(unit) { + return set.has(unit); + } + }; +} + +const config = baselineConfig(); + +describe("clean-host контракт", () => { + test("чистый хост проходит", async () => { + await expect(assertCleanHost(config, "bootstrap", probeWith([]))).resolves.toBeUndefined(); + }); + + test("каждый маркер по отдельности останавливает установку", async () => { + const markers = legacyMarkersFor(config, "bootstrap"); + expect(markers.length).toBeGreaterThan(10); + + for (const marker of markers) { + const probe = probeWith([marker.target]); + await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow( + /предыдущая или посторонняя установка/ + ); + } + }); + + test("список покрывает состояние, юниты, бинарник и наследие 0.x", () => { + const targets = legacyMarkersFor(config, "bootstrap").map((marker) => marker.target); + + for (const expected of [ + "/etc/hysteria/post-install.env", + "/etc/hy2xs/hy2xs.env", + "/var/lib/hy2xs/install-state.json", + "/etc/hy2xs/bootstrap-admin.secret", + "/usr/local/lib/hy2xs/package", + "/etc/hysteria/config.yaml", + "/usr/local/bin/hysteria", + "/etc/nftables.d/hy2xs.nft", + "hy2xs-admin.service", + "hysteria-server.service", + "h-ui.service", + "/usr/local/h-ui", + config.installDir, + config.dataDir + ]) { + expect(targets).toContain(expected); + } + }); + + test("пути из конфигурации попадают в список, а не только дефолтные", () => { + const custom = baselineConfig({ + HY2XS_INSTALL_DIR: "/srv/hy2xs-app", + HY2XS_DATA_DIR: "/srv/hy2xs-data" + }); + const targets = legacyMarkersFor(custom, "bootstrap").map((marker) => marker.target); + expect(targets).toContain("/srv/hy2xs-app"); + expect(targets).toContain("/srv/hy2xs-data"); + }); + + // install.sh раскладывает runtime-пакет между фазами, поэтому в PHASE 1 + // этот путь уже наш и маркером быть не может. + test("runtime-пакет — маркер в PHASE 0, но не в PHASE 1", async () => { + const probe = probeWith(["/usr/local/lib/hy2xs/package"]); + await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow(/usr\/local\/lib\/hy2xs\/package/); + await expect(assertCleanHost(config, "install", probe)).resolves.toBeUndefined(); + }); + + test("остальные маркеры продолжают работать и в PHASE 1", async () => { + const probe = probeWith(["hysteria-server.service"]); + await expect(assertCleanHost(config, "install", probe)).rejects.toThrow(/hysteria-server\.service/); + }); + + test("сообщение перечисляет все найденные маркеры и говорит, что хост не изменён", async () => { + const found = await detectLegacyMarkers( + legacyMarkersFor(config, "bootstrap"), + probeWith(["/etc/hy2xs/hy2xs.env", "hy2xs-admin.service"]) + ); + expect(found).toHaveLength(2); + + const message = renderLegacyFailure(found); + expect(message).toContain("/etc/hy2xs/hy2xs.env"); + expect(message).toContain("hy2xs-admin.service"); + expect(message).toContain("Ни один файл на сервере не изменён"); + expect(message).toContain("docs/14-legacy-cleanup.md"); + }); +}); diff --git a/orchestrator/test/env.test.ts b/orchestrator/test/env.test.ts index 5661f50..498248d 100644 --- a/orchestrator/test/env.test.ts +++ b/orchestrator/test/env.test.ts @@ -93,9 +93,28 @@ describe("gecko packet sizes", () => { }); describe("config schema version", () => { - test("отсутствие значения даёт текущую схему", () => { - const config = parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null })); - expect(config.configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION); + // Отсутствующий маркер — самый вероятный признак конфигурации 0.x: до v1 + // этого поля не существовало. Любой fallback здесь молча принял бы legacy. + test("отсутствие значения трактуется как legacy и отклоняется", () => { + expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow( + /HY2XS_CONFIG_SCHEMA_VERSION отсутствует/ + ); + }); + + test("отказ по отсутствующей схеме указывает на чистую установку", () => { + expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow( + /legacy-cleanup/ + ); + }); + + test("пустое значение отклоняется так же, как отсутствующее", () => { + expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: "" }))).toThrow( + /HY2XS_CONFIG_SCHEMA_VERSION отсутствует/ + ); + }); + + test("текущая схема принимается", () => { + expect(baselineConfig().configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION); }); test("схема v0/v1 отклоняется с указанием на чистую установку", () => { diff --git a/orchestrator/test/install-boundary.test.ts b/orchestrator/test/install-boundary.test.ts new file mode 100644 index 0000000..086ee79 --- /dev/null +++ b/orchestrator/test/install-boundary.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { classifyFailure } from "../src/commands/install"; +import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard"; +import { writeText, writeTextAtomic } from "../src/lib/fs"; +import { runHidden, runRawVisible, runVisible } from "../src/lib/process"; + +type Ownership = Parameters[0]; + +function ownership(overrides: Partial = {}): Ownership { + return { + stateWritten: false, + depsInstalled: false, + filesystemPrepared: false, + unitsDeployed: false, + firewallTouched: false, + postInstallWritten: false, + servicesStarted: false, + ...overrides + }; +} + +afterEach(() => { + disableReadOnlyGuard(); +}); + +describe("read-only guard (PHASE 0)", () => { + test("по умолчанию выключен", () => { + expect(isReadOnlyGuardActive()).toBe(false); + }); + + test("под guard'ом запись в файл невозможна", async () => { + enableReadOnlyGuard("test phase"); + await expect(writeText("/tmp/hy2xs-guard-probe", "x")).rejects.toThrow(/read-only guard violation/); + await expect( + writeTextAtomic("/tmp/hy2xs-guard-probe", "x", { mode: 0o600, owner: "root", group: "root" }) + ).rejects.toThrow(/read-only guard violation/); + }); + + test("под guard'ом мутирующие раннеры недоступны", async () => { + enableReadOnlyGuard("test phase"); + await expect(runVisible`true`).rejects.toThrow(/read-only guard violation/); + await expect(runHidden`true`).rejects.toThrow(/read-only guard violation/); + await expect(runRawVisible("true")).rejects.toThrow(/read-only guard violation/); + }); + + test("сообщение называет операцию и фазу", async () => { + enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); + await expect(writeText("/tmp/hy2xs-guard-probe", "x")).rejects.toThrow( + /writeText\(\/tmp\/hy2xs-guard-probe\).*PHASE 0/s + ); + }); + + test("guard снимается явно", () => { + enableReadOnlyGuard("test phase"); + expect(isReadOnlyGuardActive()).toBe(true); + disableReadOnlyGuard(); + expect(isReadOnlyGuardActive()).toBe(false); + }); +}); + +describe("классификация отказа установки", () => { + // Ключевой инвариант: пока операция ничего не применила, отказ обязан быть + // pre-apply, что бы ни было написано в тексте ошибки. + test("до любой мутации отказ — fatal_pre_apply", () => { + expect(classifyFailure(ownership(), "installing")).toBe("fatal_pre_apply"); + expect(classifyFailure(ownership(), "preflight_ok")).toBe("fatal_pre_apply"); + }); + + test("установленные пакеты уже делают отказ post-apply", () => { + expect(classifyFailure(ownership({ depsInstalled: true }), "preflight_ok")).toBe("fatal_post_apply"); + }); + + test("развёрнутые юниты без firewall — post-apply", () => { + expect(classifyFailure(ownership({ filesystemPrepared: true, unitsDeployed: true }), "units_deployed")).toBe( + "fatal_post_apply" + ); + }); + + test("тронутый firewall классифицируется как firewall failure", () => { + expect( + classifyFailure(ownership({ unitsDeployed: true, firewallTouched: true }), "firewall_applied") + ).toBe("firewall_connectivity_failure"); + }); + + test("после записи post-install env отказ — postinstall validation", () => { + expect( + classifyFailure( + ownership({ unitsDeployed: true, firewallTouched: true, postInstallWritten: true }), + "postinstall_env_written" + ) + ).toBe("postinstall_validation_failure"); + }); + + test("после старта сервисов различаются smoke и service failure", () => { + const started = ownership({ + unitsDeployed: true, + firewallTouched: true, + postInstallWritten: true, + servicesStarted: true + }); + expect(classifyFailure(started, "smoke_running")).toBe("smoke_readiness_timeout"); + expect(classifyFailure(started, "smoke_failed")).toBe("smoke_readiness_timeout"); + expect(classifyFailure(started, "services_started")).toBe("service_start_failure"); + }); + + // Регрессия: раньше классификация шла по подстрокам сообщения, поэтому + // preflight-ошибка со словом "nftables" приводила к откату чужого firewall. + test("текст ошибки не влияет на классификацию", () => { + expect(classifyFailure(ownership(), "installing")).toBe("fatal_pre_apply"); + expect(classifyFailure(ownership({ depsInstalled: true }), "deps_ok")).toBe("fatal_post_apply"); + }); +}); diff --git a/orchestrator/test/install-state.test.ts b/orchestrator/test/install-state.test.ts new file mode 100644 index 0000000..e8fa838 --- /dev/null +++ b/orchestrator/test/install-state.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { + HY2XS_PRODUCT_ID, + assertCurrentGeneration, + buildInstallStateRecord, + detectGenerationProblems, + renderGenerationFailure +} from "../src/lib/installState"; +import { HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE } from "../src/config/profile"; + +function currentState(overrides: Record = {}): Record { + return { + product: HY2XS_PRODUCT_ID, + release_line: HY2XS_RELEASE_LINE, + config_schema_version: HY2XS_CONFIG_SCHEMA_VERSION, + product_version: "1.0.0", + installed: true, + phase: "installed", + ...overrides + }; +} + +describe("идентификация поколения установки", () => { + test("маркер текущего поколения принимается", () => { + expect(detectGenerationProblems(currentState())).toEqual([]); + expect(() => assertCurrentGeneration(currentState())).not.toThrow(); + }); + + // Именно этот случай и есть «старый state marker от 0.x»: installed: true + // сам по себе ничего не доказывает. + test("маркер без полей поколения отклоняется, несмотря на installed: true", () => { + const legacy = { installed: true, phase: "installed", version: "0.0.22" }; + expect(detectGenerationProblems(legacy)).toEqual([ + "missing_product", + "missing_release_line", + "missing_config_schema" + ]); + expect(() => assertCurrentGeneration(legacy)).toThrow(/не относится к текущему поколению/); + }); + + test("чужой продукт отклоняется", () => { + expect(detectGenerationProblems(currentState({ product: "h-ui" }))).toContain("foreign_product"); + }); + + test("другая линия релиза отклоняется", () => { + expect(detectGenerationProblems(currentState({ release_line: 0 }))).toContain("foreign_release_line"); + expect(detectGenerationProblems(currentState({ release_line: 2 }))).toContain("foreign_release_line"); + }); + + test("другая схема конфигурации отклоняется", () => { + expect(detectGenerationProblems(currentState({ config_schema_version: 1 }))).toContain( + "foreign_config_schema" + ); + }); + + test("нечисловые поля поколения считаются отсутствующими", () => { + const problems = detectGenerationProblems( + currentState({ release_line: "1", config_schema_version: "2" }) + ); + expect(problems).toContain("missing_release_line"); + expect(problems).toContain("missing_config_schema"); + }); + + test("не-объект отклоняется целиком", () => { + expect(detectGenerationProblems(null)).toHaveLength(3); + expect(detectGenerationProblems("installed")).toHaveLength(3); + expect(detectGenerationProblems([1, 2])).toHaveLength(3); + }); + + test("сообщение объясняет расхождение и направляет на чистую переустановку", () => { + const state = currentState({ release_line: 0 }); + const message = renderGenerationFailure(detectGenerationProblems(state), state); + expect(message).toContain("release_line"); + expect(message).toContain("docs/14-legacy-cleanup.md"); + }); +}); + +describe("запись маркера установки", () => { + test("маркер всегда несёт идентификацию поколения", () => { + const record = buildInstallStateRecord({ + productVersion: "1.0.0", + buildId: "test-build", + opId: "op", + startedAt: "2026-08-27T00:00:00.000Z", + phase: "installed", + installed: true, + ownedPaths: ["/etc/hysteria/config.yaml"] + }); + + expect(record.product).toBe(HY2XS_PRODUCT_ID); + expect(record.release_line).toBe(HY2XS_RELEASE_LINE); + expect(record.config_schema_version).toBe(HY2XS_CONFIG_SCHEMA_VERSION); + expect(detectGenerationProblems(record)).toEqual([]); + }); + + test("успешная установка не несёт repair_hint", () => { + const record = buildInstallStateRecord({ + productVersion: "1.0.0", + buildId: "b", + opId: "op", + startedAt: "2026-08-27T00:00:00.000Z", + phase: "installed", + installed: true, + ownedPaths: [] + }); + expect(record.repair_hint).toBeUndefined(); + }); + + test("незавершённая установка подсказывает repair с явным флагом", () => { + const record = buildInstallStateRecord({ + productVersion: "1.0.0", + buildId: "b", + opId: "op", + startedAt: "2026-08-27T00:00:00.000Z", + phase: "failed", + installed: false, + ownedPaths: [], + lastError: "boom", + repairHint: "run: hy2xs-orchestrator repair --allow-partial-state" + }); + expect(record.installed).toBe(false); + expect(record.repair_hint).toContain("--allow-partial-state"); + }); +}); diff --git a/package/install.sh b/package/install.sh index 75213e3..77fae39 100755 --- a/package/install.sh +++ b/package/install.sh @@ -1,6 +1,20 @@ #!/usr/bin/env sh set -eu +# Установка HY2XS состоит из двух строго разделённых фаз. +# +# PHASE 0 — READ ONLY +# проверка прав, целостности пакета и чистоты хоста. +# Ни один persistent path не изменяется, включая /usr/local/lib/hy2xs. +# +# PHASE 1 — MUTATION +# раскладка оркестратора и runtime-пакета, затем сама установка. +# +# Инвариант: до успешного завершения PHASE 0 установщик не имеет права +# изменить на сервере ничего. HY2XS v1 не устанавливается поверх 0.x и не +# мигрирует состояние, поэтому ошибочный запуск поверх работающего сервера +# обязан быть безвредным. + PACKAGE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" ORCHESTRATOR="$PACKAGE_DIR/orchestrator/hy2xs-orchestrator" ORCHESTRATOR_INSTALL_PATH="/usr/local/lib/hy2xs/hy2xs-orchestrator" @@ -16,6 +30,10 @@ fail() { exit 1 } +# ---------------------------------------------------------------- PHASE 0 --- + +log "PHASE 0: read-only checks (no persistent path is modified)" + if [ "$(id -u)" != "0" ]; then fail "HY2XS install must run as root." fi @@ -34,6 +52,42 @@ log "verifying package checksums" sha256sum -c metadata/checksums.txt ) +# PHASE 0 принимает только источник конфигурации: флаги, влияющие на мутацию +# (--skip-*), к read-only проверке отношения не имеют. +PREFLIGHT_CONFIG="" +scan_config_argument() { + while [ "$#" -gt 0 ]; do + case "$1" in + --config) + [ "$#" -ge 2 ] || fail "Missing value for --config" + PREFLIGHT_CONFIG="$2" + shift 2 + ;; + *) + shift + ;; + esac + done +} +scan_config_argument "$@" + +# Bootstrap preflight запускается из РАСПАКОВАННОГО пакета: установленного +# оркестратора на этом этапе ещё не существует и появиться не должен. +log "running clean-host preflight from the unpacked package" +if [ -n "$PREFLIGHT_CONFIG" ]; then + "$ORCHESTRATOR" preflight-install --package-dir "$PACKAGE_DIR" --config "$PREFLIGHT_CONFIG" \ + || fail "clean-host preflight failed; the server was left untouched" +else + "$ORCHESTRATOR" preflight-install --package-dir "$PACKAGE_DIR" \ + || fail "clean-host preflight failed; the server was left untouched" +fi + +log "PHASE 0 passed" + +# ---------------------------------------------------------------- PHASE 1 --- + +log "PHASE 1: applying changes" + install -d -m 0755 /usr/local/lib/hy2xs install -m 0755 "$ORCHESTRATOR" "$ORCHESTRATOR_INSTALL_PATH" ln -sf "$ORCHESTRATOR_INSTALL_PATH" "$ORCHESTRATOR_SYMLINK"