264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
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<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",
|
|
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"
|
|
});
|
|
}
|
|
|
|
async function backupCurrentState(): Promise<void> {
|
|
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<void> {
|
|
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<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> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const effective: ReconfigureOptions = {
|
|
...options,
|
|
dryRun: false,
|
|
apply: true,
|
|
allowPartialState: true,
|
|
skipSmoke: options.skipSmoke ?? false,
|
|
skipServiceStart: options.skipServiceStart ?? false
|
|
};
|
|
await reconfigure(effective);
|
|
}
|