import type { RuntimeContext } from "../types/context"; import { dirExists, fileExists } 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 { const output = await run`ss -H -ltn`; return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`)); } catch { return false; } } async function isUdpPortListening(port: number): Promise { try { const output = await run`ss -H -lun`; return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`)); } catch { return false; } } async function isUnitActive(unit: string): Promise { try { await run`systemctl is-active --quiet ${unit}`; return true; } catch { return false; } } export async function preflight(context: RuntimeContext): Promise { const isReconfigure = context.mode === "reconfigure"; if (process.getuid?.() !== 0) { fail("installer must run as root"); } await assertPlatform({ distro: "debian", supportedVersions: [13], architectures: ["amd64"] }); if (!(await fileExists(`${context.options.packageDir}/systemd/hy2xs-admin.service`))) { fail("missing hy2xs-admin systemd unit in package"); } if (!(await fileExists(`${context.options.packageDir}/systemd/hysteria-server.service`))) { fail("missing hysteria-server systemd unit in package"); } if (!(await fileExists(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`))) { fail("missing Hysteria config template in package"); } if (context.mode === "install" && !(await fileExists(`${context.options.packageDir}/ui/hy2xs-admin/hy2xs-admin`))) { fail("bundled HY2XS admin is missing from install package"); } if (!(await fileExists("/usr/bin/apt-get")) && !(await fileExists("/bin/apt-get"))) { fail("apt-get is required on target host"); } if (!(await fileExists("/usr/bin/dpkg-query")) && !(await fileExists("/bin/dpkg-query"))) { fail("dpkg-query is required on target host"); } if (!isReconfigure) { try { await run`command -v sudo >/dev/null 2>&1`; } catch { info("sudo not found in preflight: installDeps step will install sudo before smoke checks"); } } 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"); } const ports = new Set([context.config.hysteriaPort, context.config.uiPort]); if (ports.size !== 2) { fail("Hysteria port and UI port must be different"); } if (context.config.domain && !/^[a-zA-Z0-9.-]+$/.test(context.config.domain)) { fail("domain contains unsupported characters"); } if (context.config.uiBindHost.includes(":")) { fail("HY2XS UI bind host must be IPv4-only"); } if (context.config.ipv6Enabled) { fail("HY2XS is IPv4-only: disable IPv6 in config (HY2XS_IPV6_ENABLED=false)"); } if (context.config.hysteriaBindHost !== "0.0.0.0") { fail("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 for production profile"); } if (context.config.tlsMode === "acme" && (!context.config.domain || !context.config.acmeEmail)) { fail("acme mode requires HY2XS_DOMAIN and HY2XS_ACME_EMAIL"); } if (!isReconfigure && context.config.tlsMode === "acme") { const acmeChallengePort = context.config.acmeType === "http" ? 80 : 443; if (await isTcpPortListening(acmeChallengePort)) { fail(`ACME ${context.config.acmeType}-challenge port is already in use: ${acmeChallengePort}`); } } if (context.config.domain) { try { const a = await run`getent ahostsv4 ${context.config.domain}`; if (!a.trim()) { fail(`domain has no A-record: ${context.config.domain}`); } } catch { fail(`domain has no A-record: ${context.config.domain}`); } try { const aaaa = await run`getent ahostsv6 ${context.config.domain}`; if (aaaa.trim()) { fail( `domain ${context.config.domain} has AAAA record while HY2XS profile is IPv4-only; remove AAAA record before install` ); } } catch { // no AAAA is acceptable } } const hysteriaUdpBusy = await isUdpPortListening(context.config.hysteriaPort); const uiTcpBusy = await isTcpPortListening(context.config.uiPort); if (!isReconfigure) { if (hysteriaUdpBusy) { fail(`Hysteria UDP port already appears to be in use: ${context.config.hysteriaPort}`); } if (uiTcpBusy) { fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`); } return; } if (hysteriaUdpBusy && !(await isUnitActive("hysteria-server"))) { fail(`Hysteria port ${context.config.hysteriaPort} is occupied by a non-HY2XS process`); } if (uiTcpBusy && !(await isUnitActive("hy2xs-admin"))) { fail(`HY2XS admin port ${context.config.uiPort} is occupied by a non-HY2XS process`); } }