diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index 198b671..a5ea0e1 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -1,6 +1,8 @@ import { install } from "./commands/install"; import { reconfigure } from "./commands/reconfigure"; import { doctor } from "./commands/doctor"; +import { status } from "./commands/status"; +import { diagnosticsCollect } from "./commands/diagnostics"; import type { InstallOptions, ReconfigureOptions } from "./types/context"; function usage(): never { @@ -8,6 +10,8 @@ function usage(): never { console.error(" hy2xs-orchestrator install --package-dir [--config ] [--skip-firewall] [--skip-start] [--non-interactive]"); console.error(" hy2xs-orchestrator reconfigure --package-dir [--config ] [--dry-run|--apply] [--skip-firewall] [--skip-start]"); console.error(" hy2xs-orchestrator doctor --package-dir [--config ] [--skip-firewall] [--skip-start]"); + console.error(" hy2xs-orchestrator status --package-dir [--config ] [--skip-firewall] [--skip-start]"); + console.error(" hy2xs-orchestrator diagnostics collect --package-dir [--config ] [--skip-firewall] [--skip-start]"); process.exit(2); } @@ -118,6 +122,10 @@ function parseReconfigureOptions(args: string[]): ReconfigureOptions { return options; } +function parseCommonOptions(args: string[]): InstallOptions { + return parseInstallOptions(args); +} + async function main(): Promise { const [command, ...args] = Bun.argv.slice(2); if (command === "install") { @@ -133,6 +141,18 @@ async function main(): Promise { await doctor(options); return; } + if (command === "status") { + await status(parseCommonOptions(args)); + return; + } + if (command === "diagnostics") { + const [subcommand, ...rest] = args; + if (subcommand !== "collect") { + usage(); + } + await diagnosticsCollect(parseCommonOptions(rest)); + return; + } usage(); } diff --git a/orchestrator/src/commands/diagnostics.ts b/orchestrator/src/commands/diagnostics.ts new file mode 100644 index 0000000..197431b --- /dev/null +++ b/orchestrator/src/commands/diagnostics.ts @@ -0,0 +1,27 @@ +import type { CommonOptions } from "../types/context"; +import { info, setOperationContext } from "../lib/log"; +import { run } from "../lib/process"; + +function shellEscapeSingleQuotes(value: string): string { + return value.replaceAll("'", "'\\''"); +} + +export async function diagnosticsCollect(_options: CommonOptions): Promise { + setOperationContext(`diag-${Date.now().toString(36)}`); + + const outDir = `/tmp/hy2xs-diagnostics-${Date.now()}`; + await run`mkdir -p ${outDir}`; + + await run`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`; + await run`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`; + await run`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`; + await run`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`; + await run`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`; + await run`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`; + await run`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`; + await run`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`; + await run`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`; + + info(`diagnostics bundle collected: ${outDir}`); +} + diff --git a/orchestrator/src/commands/doctor.ts b/orchestrator/src/commands/doctor.ts index a108573..5d6d0d8 100644 --- a/orchestrator/src/commands/doctor.ts +++ b/orchestrator/src/commands/doctor.ts @@ -1,12 +1,13 @@ import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import { readText } from "../lib/fs"; -import { step } from "../lib/log"; +import { setOperationContext, step, stepDone } from "../lib/log"; import { parseRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { smoke } from "../steps/smoke"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; export async function doctor(options: ReconfigureOptions): Promise { + setOperationContext(`doctor-${Date.now().toString(36)}`); const configRaw = await readText(options.sourceConfigPath); const config = parseRuntimeEnv(configRaw); @@ -22,7 +23,9 @@ export async function doctor(options: ReconfigureOptions): Promise { step("doctor preflight"); await preflight(context); + stepDone("doctor preflight"); step("doctor smoke"); await smoke(context); + stepDone("doctor smoke"); } diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 626918d..8909e51 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -1,7 +1,7 @@ import type { InstallContext, InstallOptions } from "../types/context"; import { exists, readText, writeText, writeTextAtomic } from "../lib/fs"; import { runVisible } from "../lib/process"; -import { step } from "../lib/log"; +import { setOperationContext, step, stepDone } from "../lib/log"; import { readPackageValue } from "../lib/packageMeta"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; @@ -43,6 +43,7 @@ async function rollbackFailedInstall(context: InstallContext, state: { firewallT } export async function install(options: InstallOptions): Promise { + setOperationContext(`install-${Date.now().toString(36)}`); const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false; if (options.sourceConfigPath && !hasSourceConfig) { throw new Error(`config source not found: ${options.sourceConfigPath}`); @@ -75,10 +76,13 @@ export async function install(options: InstallOptions): Promise { try { step("preflight"); await preflight(context); + stepDone("preflight"); step("system dependencies"); await installDeps(context); + stepDone("system dependencies"); step("filesystem"); await prepareFilesystem(context); + stepDone("filesystem"); step("write runtime env"); await runVisible`mkdir -p /etc/hy2xs`; await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), { @@ -86,27 +90,38 @@ export async function install(options: InstallOptions): Promise { owner: "root", group: "root" }); + stepDone("write runtime env"); step("bundled UI"); await deployUi(context); + stepDone("bundled UI"); step("Hysteria2 upstream install"); await installHysteria(context); + stepDone("Hysteria2 upstream install"); step("config generation"); await generateConfig(context); + stepDone("config generation"); step("systemd units"); await deploySystemd(context); + stepDone("systemd units"); step("firewall"); state.firewallTouched = true; await applyFirewall(context); + stepDone("firewall"); step("post-install env"); await writePostInstallEnv(context); + stepDone("post-install env"); step("bootstrap admin secret"); await writeBootstrapAdminSecret(context); + stepDone("bootstrap admin secret"); step("smoke checks"); 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); + stepDone("mark install successful"); } catch (error) { await rollbackFailedInstall(context, state); throw error; diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index 26ab7da..2092705 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -1,6 +1,6 @@ import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import { exists, readText, writeText } from "../lib/fs"; -import { info, step } from "../lib/log"; +import { info, setOperationContext, step, stepDone } from "../lib/log"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { generateConfig } from "../steps/config"; @@ -79,6 +79,7 @@ async function warnBootstrapDrift(nextConfigRaw: string): Promise { } export async function reconfigure(options: ReconfigureOptions): Promise { + setOperationContext(`reconfigure-${Date.now().toString(36)}`); const configRaw = await readText(options.sourceConfigPath); const config = parseRuntimeEnv(configRaw); @@ -94,8 +95,10 @@ export async function reconfigure(options: ReconfigureOptions): Promise { step("preflight"); await preflight(context); + stepDone("preflight"); step("install state marker"); await ensureInstallStateExists(); + stepDone("install state marker"); await warnBootstrapDrift(configRaw); if (options.dryRun) { @@ -110,23 +113,30 @@ export async function reconfigure(options: ReconfigureOptions): Promise { step("backup"); await backupCurrentState(); + stepDone("backup"); try { step("config generation"); await generateConfig(context); + stepDone("config generation"); step("systemd units"); await deploySystemd(context); + stepDone("systemd units"); step("firewall"); await applyFirewall(context); + stepDone("firewall"); 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 writePostInstallEnv(context); + stepDone("write env artifacts"); step("smoke checks"); await smoke(context); + stepDone("smoke checks"); step("finalize firewall rollback guard"); await cancelFirewallRollback(context); + stepDone("finalize firewall rollback guard"); } catch (error) { info("reconfigure failed, rollback in progress"); await rollbackFirewallNow(context); diff --git a/orchestrator/src/commands/status.ts b/orchestrator/src/commands/status.ts new file mode 100644 index 0000000..434520f --- /dev/null +++ b/orchestrator/src/commands/status.ts @@ -0,0 +1,55 @@ +import type { CommonOptions } from "../types/context"; +import { exists, readText } from "../lib/fs"; +import { info, setOperationContext } from "../lib/log"; +import { run } from "../lib/process"; +import { getPlatformProfile } from "../platform/profile"; + +async function unitState(unit: string): Promise { + try { + const out = await run`systemctl is-active ${unit}`; + return out.trim() || "unknown"; + } catch { + return "inactive"; + } +} + +async function firewallState(): Promise { + try { + await run`nft -c -f /etc/nftables.conf`; + return "valid"; + } catch { + return "invalid"; + } +} + +async function tlsState(): Promise { + if (!(await exists("/etc/hysteria/config.yaml"))) { + return "missing-config"; + } + const cfg = await readText("/etc/hysteria/config.yaml"); + if (/acme:/m.test(cfg)) { + return "acme"; + } + if (/^tls:/m.test(cfg)) { + return "file/self-signed"; + } + return "unknown"; +} + +export async function status(_options: CommonOptions): Promise { + setOperationContext(`status-${Date.now().toString(36)}`); + const platform = await getPlatformProfile(); + const result = { + ts: new Date().toISOString(), + platform, + services: { + hysteria: await unitState("hysteria-server"), + admin: await unitState("hy2xs-admin") + }, + firewall: await firewallState(), + tls: await tlsState(), + install_state_present: await exists("/var/lib/hy2xs/install-state.json") + }; + info(`status report: ${JSON.stringify(result)}`); +} + diff --git a/orchestrator/src/lib/log.ts b/orchestrator/src/lib/log.ts index c0a3e2e..5c1762b 100644 --- a/orchestrator/src/lib/log.ts +++ b/orchestrator/src/lib/log.ts @@ -1,11 +1,45 @@ +let operationId = ""; + +function nowIso(): string { + return new Date().toISOString(); +} + +function ensureOperationId(): string { + if (!operationId) { + operationId = `op-${Date.now().toString(36)}`; + } + return operationId; +} + +export function setOperationContext(id: string): void { + operationId = id; +} + +function emit(level: "STEP" | "INFO" | "ERROR", message: string, stepName?: string, status?: "start" | "ok" | "fail"): void { + const payload = { + ts: nowIso(), + op_id: ensureOperationId(), + level, + step: stepName ?? null, + status: status ?? null, + message + }; + console.log(`[hy2xs] ${JSON.stringify(payload)}`); +} + export function step(name: string): void { - console.log(`\n[hy2xs] ==> ${name}`); + emit("STEP", `==> ${name}`, name, "start"); +} + +export function stepDone(name: string): void { + emit("STEP", `<== ${name}`, name, "ok"); } export function info(message: string): void { - console.log(`[hy2xs] ${message}`); + emit("INFO", message); } export function fail(message: string): never { + emit("ERROR", message, undefined, "fail"); throw new Error(message); } diff --git a/orchestrator/src/platform/assert.ts b/orchestrator/src/platform/assert.ts new file mode 100644 index 0000000..213cb3d --- /dev/null +++ b/orchestrator/src/platform/assert.ts @@ -0,0 +1,43 @@ +import { fail } from "../lib/log"; +import { getPlatformProfile } from "./profile"; + +type AssertPlatformOptions = { + distro: "debian"; + supportedVersions: number[]; + architectures: Array<"amd64">; +}; + +export async function assertPlatform(options: AssertPlatformOptions): Promise { + const profile = await getPlatformProfile(); + + if (profile.distro !== options.distro) { + fail(`HY2XS baseline supports only ${options.distro}; detected: ${profile.distro || "unknown"}`); + } + + if (!options.supportedVersions.includes(profile.majorVersion)) { + fail( + `HY2XS baseline supports only ${options.distro} ${options.supportedVersions.join(", ")}; detected major version: ${profile.majorVersion || "unknown"}` + ); + } + + if (profile.architecture !== "amd64" || !options.architectures.includes("amd64")) { + fail(`HY2XS baseline supports only ${options.architectures.join(",")}; detected: ${profile.architecture}`); + } + + if (!profile.capabilities.systemd) { + fail("required capability missing: systemd"); + } + if (!profile.capabilities.systemdRun) { + fail("required capability missing: systemd-run"); + } + if (!profile.capabilities.nftables) { + fail("required capability missing: nft"); + } + if (!profile.capabilities.nftAtomicReplace) { + fail("required capability missing: nft atomic replace"); + } + if (!profile.capabilities.openssl3) { + fail("required capability missing: OpenSSL 3.x runtime"); + } +} + diff --git a/orchestrator/src/platform/profile.ts b/orchestrator/src/platform/profile.ts new file mode 100644 index 0000000..a4b6b76 --- /dev/null +++ b/orchestrator/src/platform/profile.ts @@ -0,0 +1,82 @@ +import { exists, readText } from "../lib/fs"; +import { run } from "../lib/process"; + +export type PlatformProfile = { + distro: string; + majorVersion: number; + architecture: "amd64" | "unsupported"; + capabilities: { + nftables: boolean; + systemd: boolean; + openssl3: boolean; + systemdRun: boolean; + nftAtomicReplace: boolean; + }; +}; + +function parseOsRelease(content: string): Record { + const result: Record = {}; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const index = line.indexOf("="); + if (index <= 0) { + continue; + } + const key = line.slice(0, index); + let value = line.slice(index + 1); + value = value.replace(/^"(.*)"$/, "$1"); + result[key] = value; + } + return result; +} + +async function commandExists(command: string): Promise { + try { + await run`command -v ${command} >/dev/null 2>&1`; + return true; + } catch { + return false; + } +} + +async function detectOpenSsl3(): Promise { + try { + const output = await run`openssl version`; + return /^OpenSSL\s+3\./.test(output); + } catch { + return false; + } +} + +export async function getPlatformProfile(): Promise { + const osReleaseRaw = await readText("/etc/os-release"); + const parsed = parseOsRelease(osReleaseRaw); + const distro = (parsed.ID || "").toLowerCase(); + const majorVersion = Number.parseInt((parsed.VERSION_ID || "").replace(/"/g, ""), 10); + + const archRaw = await run`uname -m`; + const architecture: PlatformProfile["architecture"] = archRaw.trim() === "x86_64" ? "amd64" : "unsupported"; + + const nftables = await commandExists("nft"); + const systemd = await commandExists("systemctl") && (await exists("/run/systemd/system")); + const systemdRun = await commandExists("systemd-run"); + const openssl3 = await detectOpenSsl3(); + const nftAtomicReplace = nftables; + + return { + distro, + majorVersion, + architecture, + capabilities: { + nftables, + systemd, + openssl3, + systemdRun, + nftAtomicReplace + } + }; +} + diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts index 9cc3d7a..06453f5 100644 --- a/orchestrator/src/steps/preflight.ts +++ b/orchestrator/src/steps/preflight.ts @@ -1,7 +1,8 @@ import type { RuntimeContext } from "../types/context"; -import { exists, readText } from "../lib/fs"; +import { exists } from "../lib/fs"; import { fail, info } from "../lib/log"; import { run } from "../lib/process"; +import { assertPlatform } from "../platform/assert"; async function isTcpPortListening(port: number): Promise { try { @@ -43,10 +44,11 @@ export async function preflight(context: RuntimeContext): Promise { fail("sudo is required for installer smoke checks. Install it with: apt-get update && apt-get install -y sudo"); } - const osRelease = await readText("/etc/os-release"); - if (!/^ID=debian$/m.test(osRelease) || !/^VERSION_ID="?12"?$/m.test(osRelease)) { - fail("HY2XS baseline supports only clean Debian 12"); - } + await assertPlatform({ + distro: "debian", + supportedVersions: [13], + architectures: ["amd64"] + }); if (!(await exists(`${context.options.packageDir}/systemd/hy2xs-admin.service`))) { fail("missing hy2xs-admin systemd unit in package"); diff --git a/package/docs/README.md b/package/docs/README.md index f9d04bf..2b2c12f 100644 --- a/package/docs/README.md +++ b/package/docs/README.md @@ -1,8 +1,8 @@ # HY2XS install package -Этот пакет создаётся production builder'ом на Debian 12 amd64. +Этот пакет создаётся production builder'ом на Debian 13 amd64. -Пакет предназначен для чистого Debian 12 target и содержит: +Пакет предназначен для чистого Debian 13 target и содержит: - compiled install-only orchestrator; - bundled HY2XS admin; diff --git a/package/templates/env/post-install.env.tpl b/package/templates/env/post-install.env.tpl index 1837b36..b5a6a4c 100644 --- a/package/templates/env/post-install.env.tpl +++ b/package/templates/env/post-install.env.tpl @@ -1,7 +1,7 @@ # HY2XS post-install reference file. # Файл создаётся оркестратором после первичной установки и не является runtime-конфигом. -DEPLOY_TARGET_OS=debian12 +DEPLOY_TARGET_OS=debian13 DEPLOY_TIMESTAMP={{LAST_APPLY_DATE}} PACKAGE_NAME=hy2xs-install-package PACKAGE_BUILD_ID={{PACKAGE_BUILD_ID}}