fix(install): двухфазная установка, clean-host контракт и проверка поколения

Установщик мог повредить работающий сервер до того, как откажется его
трогать: 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-бандл.
This commit is contained in:
2026-08-27 12:14:47 +05:00
parent ddf0ddf71e
commit 2b4a2cb2d5
18 changed files with 1188 additions and 179 deletions
+57 -2
View File
@@ -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 <path> [--config <path>]");
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke] [--non-interactive]");
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator repair --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator repair --package-dir <path> [--config <path>] [--allow-partial-state] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator status --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
console.error(" hy2xs-orchestrator diagnostics collect --package-dir <path> [--config <path>] [--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<void> {
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") {
+153 -103
View File
@@ -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<void> {
async function writeInstallState(
context: InstallContext,
phase: InstallPhase,
lastError: string
): Promise<void> {
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<void> {
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<void> {
await writeInstallState(context, phase, lastError);
ownership.stateWritten = true;
}
async function advanceInstallState(context: InstallContext, phase: InstallPhase, lastError = ""): Promise<void> {
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 <path> --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<void> {
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<void> {
@@ -169,32 +202,33 @@ export async function install(options: InstallOptions): Promise<void> {
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<void> {
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;
}
}
@@ -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<void> {
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");
}
+59 -59
View File
@@ -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<void> {
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<void> {
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 <path> --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<void> {
await runVisible`systemctl restart hysteria-server hy2xs-admin || true`;
}
async function ensureInstallStateExists(): Promise<void> {
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<InstallState | null> {
async function readInstallState(): Promise<InstallStateRecord | null> {
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<void> {
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<void> {
@@ -180,12 +170,14 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
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<void> {
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<void> {
}
}
/**
* repair — это тот же apply-проход, но с явным разрешением работать поверх
* незавершённой установки. Разрешение приходит флагом `--allow-partial-state`,
* а не подразумевается: молчаливое согласие на произвольный partial marker и
* было тем, что позволяло чинить «установку» чужого поколения.
*/
export async function repair(options: ReconfigureOptions): Promise<void> {
const effective: ReconfigureOptions = {
...options,
dryRun: false,
apply: true,
allowPartialState: true,
allowPartialState: options.allowPartialState ?? false,
skipSmoke: options.skipSmoke ?? false,
skipServiceStart: options.skipServiceStart ?? false
};
+8 -2
View File
@@ -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<string> {
try {
@@ -61,6 +60,9 @@ export async function status(_options: CommonOptions): Promise<void> {
? "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<void> {
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,
+14 -1
View File
@@ -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) {
+17
View File
@@ -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. */
+3
View File
@@ -1,4 +1,5 @@
import { stat } from "node:fs/promises";
import { assertMutationAllowed } from "./guard";
async function statSafe(path: string): Promise<import("node:fs").Stats | null> {
try {
@@ -30,6 +31,7 @@ export async function readText(path: string): Promise<string> {
}
export async function writeText(path: string, data: string, mode?: number): Promise<void> {
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<void> {
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)}`;
+44
View File
@@ -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"}`
);
}
+151
View File
@@ -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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Возвращает список расхождений поколения. Пустой список означает, что маркер
* принадлежит текущему поколению 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 <path> --config /etc/hy2xs/hy2xs.env --allow-partial-state";
+4
View File
@@ -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<void> {
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<void> {
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<void> {
export async function runHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
const rendered = renderCommand(command, args);
assertMutationAllowed(`runHidden(${rendered})`);
const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "inherit",
stderr: "inherit"
+201
View File
@@ -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<boolean>;
dirExists(path: string): Promise<boolean>;
unitExists(unit: string): Promise<boolean>;
};
/**
* Собирает список маркеров с учётом путей из конфигурации: 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<string>();
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<boolean> {
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<LegacyMarker[]> {
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<void> {
const found = await detectLegacyMarkers(legacyMarkersFor(config, phase), probe);
if (found.length > 0) {
throw new Error(renderLegacyFailure(found));
}
}
+14 -9
View File
@@ -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]);