Подготовить HY2XS к production-сборке

This commit is contained in:
2026-04-25 23:13:12 +05:00
commit 84a4e94567
277 changed files with 26513 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import { install } from "./commands/install";
import type { InstallOptions } from "./types/context";
function usage(): never {
console.error("Usage: hy2xs-orchestrator install --package-dir <path> [--domain <name>] [--port <udp>] [--ssh-port <tcp>] [--skip-firewall] [--skip-start] [--ui-port <tcp>] [--ui-bind-host <host>] [--non-interactive]");
process.exit(2);
}
function takeValue(args: string[], index: number, flag: string): string {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
console.error(`Missing value for ${flag}`);
usage();
}
return value;
}
function parseInstallOptions(args: string[]): InstallOptions {
const options: InstallOptions = {
packageDir: "",
nonInteractive: false,
domain: "",
port: 443,
sshPort: 22,
skipFirewall: false,
skipStart: false,
uiPort: 8080,
uiBindHost: "127.0.0.1"
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
switch (arg) {
case "--package-dir":
options.packageDir = takeValue(args, i, arg);
i += 1;
break;
case "--non-interactive":
options.nonInteractive = true;
break;
case "--domain":
options.domain = takeValue(args, i, arg);
i += 1;
break;
case "--port":
options.port = Number(takeValue(args, i, arg));
i += 1;
break;
case "--ssh-port":
options.sshPort = Number(takeValue(args, i, arg));
i += 1;
break;
case "--skip-firewall":
options.skipFirewall = true;
break;
case "--skip-start":
options.skipStart = true;
break;
case "--ui-port":
options.uiPort = Number(takeValue(args, i, arg));
i += 1;
break;
case "--ui-bind-host":
options.uiBindHost = takeValue(args, i, arg);
i += 1;
break;
default:
console.error(`Unknown argument: ${arg}`);
usage();
}
}
if (!options.packageDir) {
console.error("Missing --package-dir");
usage();
}
for (const [name, value] of Object.entries({ port: options.port, sshPort: options.sshPort, uiPort: options.uiPort })) {
if (!Number.isInteger(value) || value < 1 || value > 65535) {
console.error(`Invalid ${name}: ${value}`);
usage();
}
}
return options;
}
async function main(): Promise<void> {
const [command, ...args] = Bun.argv.slice(2);
if (command !== "install") {
usage();
}
await install(parseInstallOptions(args));
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[hy2xs] ERROR: ${message}`);
process.exit(1);
});
+61
View File
@@ -0,0 +1,61 @@
import { randomBytes } from "node:crypto";
import type { InstallContext, InstallOptions } from "../types/context";
import { readText } from "../lib/fs";
import { step } from "../lib/log";
import { preflight } from "../steps/preflight";
import { installDeps } from "../steps/deps";
import { prepareFilesystem } from "../steps/filesystem";
import { deployUi } from "../steps/ui";
import { installHysteria } from "../steps/hysteria";
import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd";
import { applyFirewall } from "../steps/firewall";
import { writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke";
async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
try {
return (await readText(`${packageDir}/metadata/${file}`)).trim();
} catch {
return fallback;
}
}
function secret(): string {
return randomBytes(24).toString("base64url");
}
export async function install(options: InstallOptions): Promise<void> {
const context: InstallContext = {
options,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaAuthPassword: secret(),
hysteriaObfsPassword: secret(),
hysteriaApiSecret: secret(),
hysteriaApiPort: 36712,
hysteriaVersion: "unknown"
};
step("preflight");
await preflight(context);
step("system dependencies");
await installDeps(context);
step("filesystem");
await prepareFilesystem(context);
step("bundled UI");
await deployUi(context);
step("Hysteria2 upstream install");
await installHysteria(context);
step("config generation");
await generateConfig(context);
step("systemd units");
await deploySystemd(context);
step("firewall");
await applyFirewall(context);
step("post-install env");
await writePostInstallEnv(context);
step("smoke checks");
await smoke(context);
}
+28
View File
@@ -0,0 +1,28 @@
export async function exists(path: string): Promise<boolean> {
return await Bun.file(path).exists();
}
export async function readText(path: string): Promise<string> {
return await Bun.file(path).text();
}
export async function writeText(path: string, data: string, mode?: number): Promise<void> {
await Bun.write(path, data);
if (mode !== undefined) {
const result = Bun.spawnSync(["chmod", mode.toString(8), path], {
stdout: "pipe",
stderr: "pipe"
});
if (!result.success) {
throw new Error(`chmod failed for ${path}: ${result.stderr.toString()}`);
}
}
}
export function renderTemplate(template: string, values: Record<string, string | number>): string {
let rendered = template;
for (const [key, value] of Object.entries(values)) {
rendered = rendered.replaceAll(`{{${key}}}`, String(value));
}
return rendered;
}
+11
View File
@@ -0,0 +1,11 @@
export function step(name: string): void {
console.log(`\n[hy2xs] ==> ${name}`);
}
export function info(message: string): void {
console.log(`[hy2xs] ${message}`);
}
export function fail(message: string): never {
throw new Error(message);
}
+50
View File
@@ -0,0 +1,50 @@
import { info } from "./log";
function shellQuote(value: unknown): string {
const text = String(value);
if (text.length === 0) {
return "''";
}
return `'${text.replaceAll("'", "'\\''")}'`;
}
function renderCommand(strings: TemplateStringsArray, values: unknown[]): string {
let command = "";
for (let i = 0; i < strings.length; i += 1) {
command += strings[i];
if (i < values.length) {
command += shellQuote(values[i]);
}
}
return command;
}
export async function run(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
const rendered = renderCommand(command, args);
const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "pipe",
stderr: "pipe"
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited
]);
if (exitCode !== 0) {
throw new Error(`command failed (${exitCode}): ${rendered}\n${stderr.trim()}`);
}
return stdout.trim();
}
export async function runVisible(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
const rendered = renderCommand(command, args);
info(`running: ${rendered}`);
const process = Bun.spawn(["sh", "-c", rendered], {
stdout: "inherit",
stderr: "inherit"
});
const exitCode = await process.exited;
if (exitCode !== 0) {
throw new Error(`command failed (${exitCode}): ${rendered}`);
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { InstallContext } from "../types/context";
import { readText, renderTemplate, writeText } from "../lib/fs";
import { runVisible } from "../lib/process";
export async function generateConfig(context: InstallContext): Promise<void> {
const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`);
const rendered = renderTemplate(template, {
HYSTERIA_PORT: context.options.port,
HYSTERIA_AUTH_PASSWORD: context.hysteriaAuthPassword,
HYSTERIA_OBFS_PASSWORD: context.hysteriaObfsPassword,
HYSTERIA_API_PORT: context.hysteriaApiPort,
HYSTERIA_API_SECRET: context.hysteriaApiSecret,
UI_PORT: context.options.uiPort,
BANDWIDTH_UP: "50 mbps",
BANDWIDTH_DOWN: "50 mbps"
});
await writeText("/etc/hysteria/config.yaml", rendered, 0o600);
await runVisible`openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj /CN=${context.options.domain || "hy2xs.local"} -keyout /etc/hysteria/server.key -out /etc/hysteria/server.crt`;
await runVisible`chown hysteria:hysteria /etc/hysteria/config.yaml /etc/hysteria/server.key /etc/hysteria/server.crt`;
}
+7
View File
@@ -0,0 +1,7 @@
import type { InstallContext } from "../types/context";
import { runVisible } from "../lib/process";
export async function installDeps(_context: InstallContext): Promise<void> {
await runVisible`apt-get update`;
await runVisible`apt-get install -y ca-certificates curl iproute2 tar openssl nftables systemd`;
}
+20
View File
@@ -0,0 +1,20 @@
import type { InstallContext } from "../types/context";
import { readText, renderTemplate, writeText } from "../lib/fs";
export async function writePostInstallEnv(context: InstallContext): Promise<void> {
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), {
PACKAGE_VERSION: context.packageVersion,
PACKAGE_BUILD_ID: context.packageBuildId,
INSTALL_DATE: context.installDate,
DOMAIN: context.options.domain,
SSH_PORT: context.options.sshPort,
HYSTERIA_VERSION: context.hysteriaVersion,
HYSTERIA_PORT: context.options.port,
HYSTERIA_OBFS_PASSWORD: context.hysteriaObfsPassword,
HYSTERIA_API_PORT: context.hysteriaApiPort,
UI_BIND_HOST: context.options.uiBindHost,
UI_PORT: context.options.uiPort
});
await writeText("/etc/hysteria/post-install.env", rendered, 0o600);
}
+9
View File
@@ -0,0 +1,9 @@
import type { InstallContext } from "../types/context";
import { runVisible } from "../lib/process";
export async function prepareFilesystem(_context: InstallContext): Promise<void> {
await runVisible`id -u hysteria >/dev/null 2>&1 || useradd --system --home /var/lib/hysteria --shell /usr/sbin/nologin hysteria`;
await runVisible`mkdir -p /etc/hysteria /var/lib/hysteria /opt/hy2xs-admin /var/lib/hy2xs-admin /var/log/hy2xs /usr/local/lib/hy2xs`;
await runVisible`chown -R hysteria:hysteria /etc/hysteria /var/lib/hysteria`;
await runVisible`chown -R root:root /var/lib/hy2xs-admin`;
}
+22
View File
@@ -0,0 +1,22 @@
import type { InstallContext } from "../types/context";
import { readText, renderTemplate, writeText } from "../lib/fs";
import { info } from "../lib/log";
import { runVisible } from "../lib/process";
export async function applyFirewall(context: InstallContext): Promise<void> {
if (context.options.skipFirewall) {
info("firewall skipped by flag");
return;
}
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), {
SSH_PORT: context.options.sshPort,
HYSTERIA_PORT: context.options.port,
UI_PORT: context.options.uiPort
});
await runVisible`cp -a /etc/nftables.conf /etc/nftables.conf.hy2xs.bak 2>/dev/null || true`;
await writeText("/etc/nftables.conf", rendered, 0o644);
await runVisible`nft -f /etc/nftables.conf`;
await runVisible`systemctl enable --now nftables`;
}
+8
View File
@@ -0,0 +1,8 @@
import type { InstallContext } from "../types/context";
import { run, runVisible } from "../lib/process";
export async function installHysteria(context: InstallContext): Promise<void> {
await runVisible`curl -fsSL https://get.hy2.sh/ -o /tmp/hy2xs-install-hysteria.sh`;
await runVisible`sh /tmp/hy2xs-install-hysteria.sh`;
context.hysteriaVersion = await run`/usr/local/bin/hysteria version`;
}
+53
View File
@@ -0,0 +1,53 @@
import type { InstallContext } from "../types/context";
import { exists, readText } from "../lib/fs";
import { fail } from "../lib/log";
import { run } from "../lib/process";
async function isPortBusy(port: number): Promise<boolean> {
try {
const output = await run`ss -H -lntu`;
return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
} catch {
return false;
}
}
export async function preflight(context: InstallContext): Promise<void> {
if (process.getuid?.() !== 0) {
fail("installer must run as root");
}
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");
}
if (!(await exists(`${context.options.packageDir}/ui/hy2xs-admin`))) {
fail("bundled HY2XS admin is missing from install package");
}
if (await exists("/etc/hysteria/post-install.env")) {
fail("existing HY2XS post-install.env found; update/repair is out of scope");
}
if (await exists("/opt/hy2xs-admin")) {
fail("existing /opt/hy2xs-admin found; conflicting old state");
}
const ports = new Set([context.options.port, context.options.uiPort]);
if (ports.size !== 2) {
fail("Hysteria port and UI port must be different");
}
if (context.options.domain && !/^[a-zA-Z0-9.-]+$/.test(context.options.domain)) {
fail("domain contains unsupported characters");
}
if (await isPortBusy(context.options.port)) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.options.port}`);
}
if (await isPortBusy(context.options.uiPort)) {
fail(`HY2XS admin port already appears to be in use: ${context.options.uiPort}`);
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { InstallContext } from "../types/context";
import { info } from "../lib/log";
import { runVisible } from "../lib/process";
export async function smoke(context: InstallContext): Promise<void> {
if (context.options.skipStart) {
info("service start and smoke checks skipped by flag");
return;
}
await runVisible`systemctl start hysteria-server hy2xs-admin`;
await runVisible`systemctl is-active --quiet hysteria-server`;
await runVisible`systemctl is-active --quiet hy2xs-admin`;
await runVisible`/usr/local/bin/hysteria version`;
await runVisible`test -s /etc/hysteria/config.yaml`;
await runVisible`test -s /etc/hysteria/post-install.env`;
await runVisible`ss -H -lntu | grep -q ':${context.options.uiPort} '`;
await runVisible`curl -fsS --max-time 5 http://127.0.0.1:${context.options.uiPort}/ >/dev/null`;
}
+18
View File
@@ -0,0 +1,18 @@
import type { InstallContext } from "../types/context";
import { readText, renderTemplate, writeText } from "../lib/fs";
import { runVisible } from "../lib/process";
export async function deploySystemd(context: InstallContext): Promise<void> {
const values = {
UI_BIND_HOST: context.options.uiBindHost,
UI_PORT: context.options.uiPort
};
const hysteriaUnit = await readText(`${context.options.packageDir}/systemd/hysteria-server.service`);
const adminUnit = renderTemplate(await readText(`${context.options.packageDir}/systemd/hy2xs-admin.service`), values);
await writeText("/etc/systemd/system/hysteria-server.service", hysteriaUnit, 0o644);
await writeText("/etc/systemd/system/hy2xs-admin.service", adminUnit, 0o644);
await runVisible`systemctl daemon-reload`;
await runVisible`systemctl enable hysteria-server hy2xs-admin`;
}
+8
View File
@@ -0,0 +1,8 @@
import type { InstallContext } from "../types/context";
import { runVisible } from "../lib/process";
export async function deployUi(context: InstallContext): Promise<void> {
await runVisible`cp -a ${context.options.packageDir}/ui/hy2xs-admin/. /opt/hy2xs-admin/`;
await runVisible`chown -R root:root /opt/hy2xs-admin`;
await runVisible`chmod -R go-w /opt/hy2xs-admin`;
}
+23
View File
@@ -0,0 +1,23 @@
export type InstallOptions = {
packageDir: string;
nonInteractive: boolean;
domain: string;
port: number;
sshPort: number;
skipFirewall: boolean;
skipStart: boolean;
uiPort: number;
uiBindHost: string;
};
export type InstallContext = {
options: InstallOptions;
packageVersion: string;
packageBuildId: string;
installDate: string;
hysteriaAuthPassword: string;
hysteriaObfsPassword: string;
hysteriaApiSecret: string;
hysteriaApiPort: number;
hysteriaVersion: string;
};