import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import { fileExists, readText, writeText } from "../lib/fs"; import { info, setOperationContext, step, stepDone } from "../lib/log"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; 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" | "config_generated" | "units_deployed" | "firewall_applied" | "runtime_env_written" | "smoke_ok" | "firewall_connectivity_failure" | "smoke_failed" | "installed"; function operationKey(context: ReconfigureContext): string { return context.installDate; } function ownedPaths(context: ReconfigureContext): string[] { return [ context.options.runtimeConfigPath, "/etc/hysteria/config.yaml", "/etc/hysteria/post-install.env", context.config.bootstrapAdminSecretPath, "/etc/systemd/system/hy2xs-admin.service", "/etc/systemd/system/hysteria-server.service", "/etc/nftables.conf", "/etc/nftables.d/hy2xs.nft", context.config.installDir ]; } async function writeInstallState(state: InstallState): Promise { await writeText(INSTALL_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, 0o644); await runVisible`chown root:root ${INSTALL_STATE_PATH}`; } async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, lastError = ""): Promise { await writeInstallState({ installed: phase === "installed", phase, version: context.packageVersion, build_id: context.packageBuildId, op_id: operationKey(context), started_at: context.installDate, updated_at: new Date().toISOString(), owned_paths: ownedPaths(context), last_error: lastError, repair_hint: phase === "installed" ? "" : "run: hy2xs-orchestrator repair --package-dir --config /etc/hy2xs/hy2xs.env" }); } async function backupCurrentState(): Promise { await runVisible`mkdir -p /etc/hy2xs/backups`; await runVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`; await runVisible`cp -a /etc/systemd/system/hy2xs-admin.service /etc/hy2xs/backups/hy2xs-admin.service.bak 2>/dev/null || true`; await runVisible`cp -a /etc/systemd/system/hysteria-server.service /etc/hy2xs/backups/hysteria-server.service.bak 2>/dev/null || true`; await runVisible`cp -a /etc/hy2xs/hy2xs.env /etc/hy2xs/backups/hy2xs.env.bak 2>/dev/null || true`; await runVisible`cp -a /etc/hysteria/post-install.env /etc/hy2xs/backups/post-install.env.bak 2>/dev/null || true`; await runVisible`cp -a /etc/nftables.conf /etc/hy2xs/backups/nftables.conf.bak 2>/dev/null || true`; await runVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`; await runVisible`test -f /etc/hy2xs/hy2xs.env && echo 1 > /etc/hy2xs/backups/hy2xs.env.existed || rm -f /etc/hy2xs/backups/hy2xs.env.existed`; await runVisible`test -f /etc/hysteria/post-install.env && echo 1 > /etc/hy2xs/backups/post-install.env.existed || rm -f /etc/hy2xs/backups/post-install.env.existed`; await runVisible`test -f /etc/nftables.conf && echo 1 > /etc/hy2xs/backups/nftables.conf.existed || rm -f /etc/hy2xs/backups/nftables.conf.existed`; await runVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > /etc/hy2xs/backups/hy2xs.nft.existed || rm -f /etc/hy2xs/backups/hy2xs.nft.existed`; } async function rollbackCurrentState(): Promise { await runVisible`cp -a /etc/hy2xs/backups/config.yaml.bak /etc/hysteria/config.yaml 2>/dev/null || true`; await runVisible`cp -a /etc/hy2xs/backups/hy2xs-admin.service.bak /etc/systemd/system/hy2xs-admin.service 2>/dev/null || true`; await runVisible`cp -a /etc/hy2xs/backups/hysteria-server.service.bak /etc/systemd/system/hysteria-server.service 2>/dev/null || true`; await runVisible`if [ -f /etc/hy2xs/backups/hy2xs.env.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.env.bak /etc/hy2xs/hy2xs.env 2>/dev/null || true; else rm -f /etc/hy2xs/hy2xs.env; fi`; await runVisible`if [ -f /etc/hy2xs/backups/post-install.env.existed ]; then cp -a /etc/hy2xs/backups/post-install.env.bak /etc/hysteria/post-install.env 2>/dev/null || true; else rm -f /etc/hysteria/post-install.env; fi`; await runVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; fi`; await runVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; await runVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`; await runVisible`systemctl daemon-reload`; await runVisible`systemctl restart hysteria-server hy2xs-admin || true`; } async function ensureInstallStateExists(): Promise { if (!(await fileExists(INSTALL_STATE_PATH))) { throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`); } const raw = await readText(INSTALL_STATE_PATH); let parsed: InstallState; try { parsed = JSON.parse(raw) as InstallState; } catch { throw new Error(`invalid install state marker format: ${INSTALL_STATE_PATH}`); } if (!parsed.installed) { throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`); } } async function readInstallState(): Promise { if (!(await fileExists(INSTALL_STATE_PATH))) { return null; } try { return JSON.parse(await readText(INSTALL_STATE_PATH)) as InstallState; } catch { return null; } } async function ensureInstallStateForOperation(options: ReconfigureOptions): Promise { const state = await readInstallState(); if (!state) { throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`); } if (state.installed) { return; } if (options.allowPartialState) { 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}`); } async function warnBootstrapDrift(nextConfigRaw: string): Promise { if (!(await fileExists("/etc/hy2xs/hy2xs.env"))) { return; } const prev = parseRuntimeEnv(await readText("/etc/hy2xs/hy2xs.env")); const next = parseRuntimeEnv(nextConfigRaw); if (prev.adminInitialPassword !== next.adminInitialPassword || prev.adminConPass !== next.adminConPass) { info("warning: Admin bootstrap fields are install-only and will not rotate existing credentials."); info("warning: Use a dedicated password rotation flow in application/account layer."); } } export async function reconfigure(options: ReconfigureOptions): Promise { setOperationContext(`reconfigure-${Date.now().toString(36)}`); const configRaw = await readText(options.sourceConfigPath); const config = parseRuntimeEnv(configRaw); const context: ReconfigureContext = { mode: "reconfigure", options, config, packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"), packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"), installDate: new Date().toISOString(), hysteriaVersion: await readInstalledHysteriaVersion() }; step("preflight"); await preflight(context); stepDone("preflight"); step("install state marker"); await ensureInstallStateForOperation(options); stepDone("install state marker"); await warnBootstrapDrift(configRaw); if (options.dryRun) { info("reconfigure dry-run: validated config and execution graph"); info(`config source: ${options.sourceConfigPath}`); info(`runtime file: ${options.runtimeConfigPath}`); info(`ui bind: ${config.uiBindHost}:${config.uiPort}`); info(`hysteria bind: ${config.hysteriaBindHost}:${config.hysteriaPort}`); info(`public endpoint: ${config.publicHost}:${config.publicPort}`); return; } step("backup"); await backupCurrentState(); stepDone("backup"); try { await markPhase(context, options.allowPartialState ? "repairing" : "reconfiguring"); step("config generation"); await generateConfig(context); stepDone("config generation"); await markPhase(context, "config_generated"); step("systemd units"); await deploySystemd(context); stepDone("systemd units"); await markPhase(context, "units_deployed"); step("firewall"); await applyFirewall(context); 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 ensureBootstrapAdminSecret(context); await writePostInstallEnv(context); stepDone("write env artifacts"); await markPhase(context, "runtime_env_written"); step("smoke checks"); await smoke(context); stepDone("smoke checks"); await markPhase(context, "smoke_ok"); step("finalize firewall rollback guard"); await cancelFirewallRollback(context); stepDone("finalize firewall rollback guard"); await markPhase(context, "installed"); const finalState = await readInstallState(); if (!finalState?.installed || finalState.phase !== "installed") { throw new Error("deterministic state violation: reconfigure/repair finished without installed phase"); } } catch (error) { info("reconfigure failed, rollback in progress"); const message = error instanceof Error ? error.message : String(error); const phase: ReconfigurePhase = /firewall|nft|ssh port check failed/i.test(message) ? "firewall_connectivity_failure" : "smoke_failed"; await markPhase(context, phase, message); await diagnosticsCollect(options); await rollbackFirewallNow(context); await rollbackCurrentState(); throw error; } } export async function repair(options: ReconfigureOptions): Promise { const effective: ReconfigureOptions = { ...options, dryRun: false, apply: true, allowPartialState: true, skipSmoke: options.skipSmoke ?? false, skipServiceStart: options.skipServiceStart ?? false }; await reconfigure(effective); }