158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
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<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
try {
|
|
await run`systemctl is-active --quiet ${unit}`;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function preflight(context: RuntimeContext): Promise<void> {
|
|
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`);
|
|
}
|
|
}
|