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:
+57
-2
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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"}`
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user