Реализован production-hardening по fix1: env/reconfigure, IPv4-only, TLS, secrets, firewall, docs

This commit is contained in:
2026-04-26 07:27:06 +05:00
parent 2b4a45ad23
commit 3fccd5c442
109 changed files with 1773 additions and 569 deletions
+67 -34
View File
@@ -1,8 +1,11 @@
import { install } from "./commands/install";
import type { InstallOptions } from "./types/context";
import { reconfigure } from "./commands/reconfigure";
import type { InstallOptions, ReconfigureOptions } 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]");
console.error("Usage:");
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start] [--non-interactive]");
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-start]");
process.exit(2);
}
@@ -18,14 +21,10 @@ function takeValue(args: string[], index: number, flag: string): string {
function parseInstallOptions(args: string[]): InstallOptions {
const options: InstallOptions = {
packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false,
domain: "",
port: 443,
sshPort: 22,
skipFirewall: false,
skipStart: false,
uiPort: 8080,
uiBindHost: "127.0.0.1"
skipStart: false
};
for (let i = 0; i < args.length; i += 1) {
@@ -38,30 +37,14 @@ function parseInstallOptions(args: string[]): InstallOptions {
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);
case "--config":
options.configPath = takeValue(args, i, arg);
i += 1;
break;
default:
@@ -75,23 +58,73 @@ function parseInstallOptions(args: string[]): InstallOptions {
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;
}
function parseReconfigureOptions(args: string[]): ReconfigureOptions {
const options: ReconfigureOptions = {
packageDir: "",
configPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: false,
dryRun: false,
apply: false,
skipFirewall: false,
skipStart: false
};
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 "--config":
options.configPath = takeValue(args, i, arg);
i += 1;
break;
case "--dry-run":
options.dryRun = true;
break;
case "--apply":
options.apply = true;
break;
case "--skip-firewall":
options.skipFirewall = true;
break;
case "--skip-start":
options.skipStart = true;
break;
default:
console.error(`Unknown argument: ${arg}`);
usage();
}
}
if (!options.packageDir) {
console.error("Missing --package-dir");
usage();
}
if (options.dryRun === options.apply) {
console.error("Specify exactly one of --dry-run or --apply");
usage();
}
return options;
}
async function main(): Promise<void> {
const [command, ...args] = Bun.argv.slice(2);
if (command !== "install") {
usage();
if (command === "install") {
await install(parseInstallOptions(args));
return;
}
await install(parseInstallOptions(args));
if (command === "reconfigure") {
await reconfigure(parseReconfigureOptions(args));
return;
}
usage();
}
main().catch((error: unknown) => {
+12 -4
View File
@@ -1,7 +1,9 @@
import { randomBytes } from "node:crypto";
import type { InstallContext, InstallOptions } from "../types/context";
import { readText } from "../lib/fs";
import { exists, readText, writeText } from "../lib/fs";
import { runVisible } from "../lib/process";
import { step } from "../lib/log";
import { defaultRuntimeConfig, parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
import { installDeps } from "../steps/deps";
import { prepareFilesystem } from "../steps/filesystem";
@@ -26,15 +28,16 @@ function secret(): string {
}
export async function install(options: InstallOptions): Promise<void> {
const hasConfig = await exists(options.configPath);
const config = hasConfig ? parseRuntimeEnv(await readText(options.configPath)) : defaultRuntimeConfig();
const context: InstallContext = {
options,
config,
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"
};
@@ -44,6 +47,11 @@ export async function install(options: InstallOptions): Promise<void> {
await installDeps(context);
step("filesystem");
await prepareFilesystem(context);
if (!hasConfig) {
step("write runtime env");
await runVisible`mkdir -p /etc/hy2xs`;
await writeText(options.configPath, renderRuntimeEnv(config), 0o600);
}
step("bundled UI");
await deployUi(context);
step("Hysteria2 upstream install");
+76
View File
@@ -0,0 +1,76 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { readText, writeText } from "../lib/fs";
import { info, step } 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 } from "../steps/firewall";
import { writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke";
import { runVisible } from "../lib/process";
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/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`;
}
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`cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true`;
await runVisible`systemctl daemon-reload`;
await runVisible`systemctl restart hysteria-server hy2xs-admin || true`;
}
export async function reconfigure(options: ReconfigureOptions): Promise<void> {
const configRaw = await readText(options.configPath);
const config = parseRuntimeEnv(configRaw);
const context: ReconfigureContext & { packageVersion: string; packageBuildId: string; installDate: string; hysteriaAuthPassword: string; hysteriaVersion: string } = {
options,
config,
packageVersion: "reconfigure",
packageBuildId: "reconfigure",
installDate: new Date().toISOString(),
hysteriaAuthPassword: "managed-by-ui-auth",
hysteriaVersion: "unknown"
};
step("preflight");
await preflight(context);
if (options.dryRun) {
info("reconfigure dry-run: validated config and execution graph");
info(`config file: ${options.configPath}`);
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();
try {
step("config generation");
await generateConfig(context);
step("systemd units");
await deploySystemd(context);
step("firewall");
await applyFirewall(context);
step("write env artifacts");
await writeText(options.configPath, renderRuntimeEnv(config), 0o600);
await writePostInstallEnv(context);
step("smoke checks");
await smoke(context);
} catch (error) {
info("reconfigure failed, rollback in progress");
await rollbackCurrentState();
throw error;
}
}
+253
View File
@@ -0,0 +1,253 @@
import { randomBytes } from "node:crypto";
import type { RuntimeConfig, TlsMode } from "../types/context";
type EnvMap = Record<string, string>;
function randomSecret(): string {
return randomBytes(24).toString("base64url");
}
function parseEnv(content: string): EnvMap {
const result: EnvMap = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separator = line.indexOf("=");
if (separator < 1) {
throw new Error(`invalid env line: ${rawLine}`);
}
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
result[key] = value;
}
return result;
}
function parsePort(name: string, raw: string, fallback: number): number {
const value = raw ? Number(raw) : fallback;
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
function parseBool(name: string, raw: string, fallback: boolean): boolean {
if (!raw) {
return fallback;
}
if (raw === "true") {
return true;
}
if (raw === "false") {
return false;
}
throw new Error(`invalid ${name}: ${raw}`);
}
function requireValue(name: string, value: string): string {
if (!value || !value.trim()) {
throw new Error(`missing required ${name}`);
}
return value.trim();
}
function normalizeIpv4Host(name: string, value: string): string {
if (value === "0.0.0.0" || value === "127.0.0.1") {
return value;
}
if (/^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(value)) {
return value;
}
if (value.includes(":")) {
throw new Error(`${name} must be IPv4-only`);
}
throw new Error(`invalid ${name}: ${value}`);
}
function normalizePublicHost(value: string): string {
if (!value) {
throw new Error("missing required HY2XS_PUBLIC_HOST");
}
if (value.includes(":")) {
throw new Error("HY2XS_PUBLIC_HOST must not contain IPv6");
}
return value;
}
function normalizeTlsMode(value: string): TlsMode {
if (value === "acme" || value === "file" || value === "self_signed_dev") {
return value;
}
throw new Error(`invalid HY2XS_TLS_MODE: ${value}`);
}
export function parseRuntimeEnv(content: string): RuntimeConfig {
const env = parseEnv(content);
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
const uiBindHost = normalizeIpv4Host("HY2XS_UI_BIND_HOST", env.HY2XS_UI_BIND_HOST || "127.0.0.1");
const hysteriaPort = parsePort("HY2XS_HYSTERIA_PORT", env.HY2XS_HYSTERIA_PORT, 443);
const trafficStatsPort = parsePort("HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", env.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT, 36712);
const tlsMode = normalizeTlsMode(env.HY2XS_TLS_MODE || "acme");
const config: RuntimeConfig = {
domain: env.HY2XS_DOMAIN || "",
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
publicPort: parsePort("HY2XS_PUBLIC_PORT", env.HY2XS_PUBLIC_PORT, hysteriaPort),
ipv6Enabled: false,
sshPort: parsePort("HY2XS_SSH_PORT", env.HY2XS_SSH_PORT, 22),
firewallEnabled: parseBool("HY2XS_FIREWALL_ENABLED", env.HY2XS_FIREWALL_ENABLED, true),
firewallStagedApply: parseBool("HY2XS_FIREWALL_STAGED_APPLY", env.HY2XS_FIREWALL_STAGED_APPLY, true),
uiBindHost,
uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
adminInitialPassword: env.HY2XS_ADMIN_INITIAL_PASSWORD || randomSecret(),
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, true),
tlsMode,
acmeEmail: env.HY2XS_ACME_EMAIL || "",
tlsCertPath: env.HY2XS_TLS_CERT_PATH || "/etc/hysteria/server.crt",
tlsKeyPath: env.HY2XS_TLS_KEY_PATH || "/etc/hysteria/server.key",
hysteriaBindHost: normalizeIpv4Host("HY2XS_HYSTERIA_BIND_HOST", env.HY2XS_HYSTERIA_BIND_HOST || "0.0.0.0"),
hysteriaPort,
hysteriaAuthMode: "http",
hysteriaAuthUrl: env.HY2XS_HYSTERIA_AUTH_URL || `http://127.0.0.1:${uiPort}/hui/hysteria2/auth`,
hysteriaTrafficStatsHost: normalizeIpv4Host(
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST",
env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1"
),
hysteriaTrafficStatsPort: trafficStatsPort,
hysteriaTrafficStatsSecret: env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET || randomSecret(),
hysteriaObfsType: "salamander",
hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", env.HY2XS_HYSTERIA_OBFS_PASSWORD || randomSecret()),
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool(
"HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH",
env.HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH,
false
),
hysteriaConfigPath: env.HY2XS_HYSTERIA_CONFIG_PATH || "/etc/hysteria/config.yaml",
hysteriaVersionPolicy: env.HY2XS_HYSTERIA_VERSION || "latest",
installDir: env.HY2XS_INSTALL_DIR || "/opt/hy2xs-admin",
dataDir: env.HY2XS_DATA_DIR || "/var/lib/hy2xs-admin",
logDir: env.HY2XS_LOG_DIR || "/var/log/hy2xs",
bootstrapAdminSecretPath: "/etc/hy2xs/bootstrap-admin.secret"
};
validateRuntimeConfig(config);
return config;
}
export function defaultRuntimeConfig(): RuntimeConfig {
return parseRuntimeEnv(
[
"HY2XS_IPV6_ENABLED=false",
"HY2XS_DOMAIN=",
"HY2XS_PUBLIC_HOST=127.0.0.1",
"HY2XS_PUBLIC_PORT=443",
"HY2XS_SSH_PORT=22",
"HY2XS_FIREWALL_ENABLED=true",
"HY2XS_FIREWALL_STAGED_APPLY=true",
"HY2XS_UI_BIND_HOST=127.0.0.1",
"HY2XS_UI_PORT=8080",
"HY2XS_ADMIN_USER=admin",
`HY2XS_ADMIN_INITIAL_PASSWORD=${randomSecret()}`,
"HY2XS_FORCE_PASSWORD_CHANGE=true",
"HY2XS_TLS_MODE=self_signed_dev",
"HY2XS_ACME_EMAIL=",
"HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt",
"HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key",
"HY2XS_HYSTERIA_BIND_HOST=0.0.0.0",
"HY2XS_HYSTERIA_PORT=443",
"HY2XS_HYSTERIA_AUTH_MODE=http",
"HY2XS_HYSTERIA_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth",
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1",
"HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712",
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${randomSecret()}`,
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
`HY2XS_HYSTERIA_OBFS_PASSWORD=${randomSecret()}`,
"HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps",
"HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps",
"HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false",
"HY2XS_HYSTERIA_CONFIG_PATH=/etc/hysteria/config.yaml",
"HY2XS_HYSTERIA_VERSION=latest",
"HY2XS_INSTALL_DIR=/opt/hy2xs-admin",
"HY2XS_DATA_DIR=/var/lib/hy2xs-admin",
"HY2XS_LOG_DIR=/var/log/hy2xs"
].join("\n")
);
}
export function validateRuntimeConfig(config: RuntimeConfig): void {
if (config.ipv6Enabled) {
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false");
}
if (config.uiBindHost.includes(":")) {
throw new Error("HY2XS_UI_BIND_HOST must be IPv4-only");
}
if (config.hysteriaBindHost.includes(":")) {
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be IPv4-only");
}
if (config.hysteriaBindHost !== "0.0.0.0") {
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 in production profile");
}
if (config.tlsMode === "acme") {
if (!config.domain) {
throw new Error("HY2XS_DOMAIN is required for TLS mode acme");
}
if (!config.acmeEmail) {
throw new Error("HY2XS_ACME_EMAIL is required for TLS mode acme");
}
}
if (config.tlsMode === "file") {
requireValue("HY2XS_TLS_CERT_PATH", config.tlsCertPath);
requireValue("HY2XS_TLS_KEY_PATH", config.tlsKeyPath);
}
if (config.publicHost === "0.0.0.0") {
throw new Error("HY2XS_PUBLIC_HOST must be a routable domain or IPv4, not 0.0.0.0");
}
}
export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [
"# HY2XS runtime config (editable)",
"HY2XS_IPV6_ENABLED=false",
`HY2XS_DOMAIN=${config.domain}`,
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
`HY2XS_PUBLIC_PORT=${config.publicPort}`,
`HY2XS_SSH_PORT=${config.sshPort}`,
`HY2XS_FIREWALL_ENABLED=${config.firewallEnabled}`,
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
`HY2XS_UI_PORT=${config.uiPort}`,
`HY2XS_ADMIN_USER=${config.adminUser}`,
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
`HY2XS_TLS_MODE=${config.tlsMode}`,
`HY2XS_ACME_EMAIL=${config.acmeEmail}`,
`HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`,
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
"HY2XS_HYSTERIA_AUTH_MODE=http",
`HY2XS_HYSTERIA_AUTH_URL=${config.hysteriaAuthUrl}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`,
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`,
`HY2XS_HYSTERIA_VERSION=${config.hysteriaVersionPolicy}`,
`HY2XS_INSTALL_DIR=${config.installDir}`,
`HY2XS_DATA_DIR=${config.dataDir}`,
`HY2XS_LOG_DIR=${config.logDir}`
];
return `${lines.join("\n")}\n`;
}
+31 -10
View File
@@ -3,19 +3,40 @@ import { readText, renderTemplate, writeText } from "../lib/fs";
import { runVisible } from "../lib/process";
export async function generateConfig(context: InstallContext): Promise<void> {
const tlsAcmeBlock = context.config.tlsMode === "acme"
? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0`
: "";
const tlsFileBlock = context.config.tlsMode === "file" || context.config.tlsMode === "self_signed_dev"
? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}`
: "";
const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`);
const rendered = renderTemplate(template, {
HYSTERIA_PORT: context.options.port,
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort,
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"
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
UI_PORT: context.config.uiPort,
BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
TLS_ACME_BLOCK: tlsAcmeBlock,
TLS_FILE_BLOCK: tlsFileBlock,
AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false"
});
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`;
await writeText("/etc/hysteria/config.yaml.tmp", rendered, 0o600);
await runVisible`mv /etc/hysteria/config.yaml.tmp /etc/hysteria/config.yaml`;
if (context.config.tlsMode === "self_signed_dev") {
await runVisible`openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj /CN=${context.config.domain || "hy2xs.local"} -keyout ${context.config.tlsKeyPath} -out ${context.config.tlsCertPath}`;
await runVisible`chmod 600 ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`;
}
await runVisible`chown hysteria:hysteria /etc/hysteria/config.yaml`;
if (context.config.tlsMode !== "acme") {
await runVisible`chown hysteria:hysteria ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`;
}
}
+21 -7
View File
@@ -6,15 +6,29 @@ export async function writePostInstallEnv(context: InstallContext): Promise<void
PACKAGE_VERSION: context.packageVersion,
PACKAGE_BUILD_ID: context.packageBuildId,
INSTALL_DATE: context.installDate,
DOMAIN: context.options.domain,
SSH_PORT: context.options.sshPort,
DOMAIN: context.config.domain,
PUBLIC_HOST: context.config.publicHost,
PUBLIC_PORT: context.config.publicPort,
SSH_PORT: context.config.sshPort,
FIREWALL_ENABLED: context.config.firewallEnabled ? "true" : "false",
FIREWALL_STAGED_APPLY: context.config.firewallStagedApply ? "true" : "false",
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
TLS_MODE: context.config.tlsMode,
ACME_EMAIL: context.config.acmeEmail,
TLS_CERT_PATH: context.config.tlsCertPath,
TLS_KEY_PATH: context.config.tlsKeyPath,
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
HYSTERIA_PORT: context.config.hysteriaPort,
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
UI_BIND_HOST: context.config.uiBindHost,
UI_PORT: context.config.uiPort,
INSTALL_DIR: context.config.installDir,
DATA_DIR: context.config.dataDir,
LOG_DIR: context.config.logDir
});
await writeText("/etc/hysteria/post-install.env", rendered, 0o600);
await writeText(context.config.bootstrapAdminSecretPath, `${context.config.adminUser}:${context.config.adminInitialPassword}\n`, 0o600);
}
+7 -3
View File
@@ -1,9 +1,13 @@
import type { InstallContext } from "../types/context";
import { runVisible } from "../lib/process";
export async function prepareFilesystem(_context: InstallContext): Promise<void> {
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`id -u hy2xs-admin >/dev/null 2>&1 || useradd --system --home ${context.config.dataDir} --shell /usr/sbin/nologin hy2xs-admin`;
await runVisible`mkdir -p /etc/hy2xs /etc/hysteria /var/lib/hysteria ${context.config.installDir} ${context.config.dataDir} ${context.config.logDir} /usr/local/lib/hy2xs /etc/nftables.d`;
await runVisible`chown -R hysteria:hysteria /etc/hysteria /var/lib/hysteria`;
await runVisible`chown -R root:root /var/lib/hy2xs-admin`;
await runVisible`chown -R root:root ${context.config.installDir}`;
await runVisible`chmod -R go-w ${context.config.installDir}`;
await runVisible`chown -R hy2xs-admin:hy2xs-admin ${context.config.dataDir} ${context.config.logDir}`;
await runVisible`chmod 700 /etc/hy2xs`;
}
+23 -6
View File
@@ -4,19 +4,36 @@ import { info } from "../lib/log";
import { runVisible } from "../lib/process";
export async function applyFirewall(context: InstallContext): Promise<void> {
if (context.options.skipFirewall) {
if (context.options.skipFirewall || !context.config.firewallEnabled) {
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
SSH_PORT: context.config.sshPort,
HYSTERIA_PORT: context.config.hysteriaPort,
UI_PORT: context.config.uiPort,
UI_BIND_HOST: context.config.uiBindHost
});
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`cp -a /etc/nftables.d/hy2xs.nft /etc/nftables.d/hy2xs.nft.bak 2>/dev/null || true`;
await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600);
await runVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`;
if (context.config.firewallStagedApply) {
await runVisible`systemd-run --unit hy2xs-fw-rollback --on-active=45s /bin/sh -c 'cp -a /etc/nftables.d/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; nft -f /etc/nftables.conf >/dev/null 2>&1 || true'`;
}
await runVisible`mv /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft`;
await runVisible`grep -q 'include "/etc/nftables.d/hy2xs.nft"' /etc/nftables.conf || printf '\ninclude "/etc/nftables.d/hy2xs.nft"\n' >> /etc/nftables.conf`;
await runVisible`nft -f /etc/nftables.conf`;
await runVisible`systemctl enable --now nftables`;
await runVisible`ss -H -ltn | grep -q ':${context.config.sshPort} ' || (echo 'ssh port check failed' >&2; exit 1)`;
await runVisible`ss -H -lun | grep -q ':${context.config.hysteriaPort} ' || (echo 'hysteria udp port check failed' >&2; exit 1)`;
if (context.config.firewallStagedApply) {
await runVisible`systemctl stop hy2xs-fw-rollback || true`;
await runVisible`systemctl reset-failed hy2xs-fw-rollback || true`;
}
}
+42 -9
View File
@@ -1,6 +1,6 @@
import type { InstallContext } from "../types/context";
import { exists, readText } from "../lib/fs";
import { fail } from "../lib/log";
import { fail, info } from "../lib/log";
import { run } from "../lib/process";
async function isPortBusy(port: number): Promise<boolean> {
@@ -13,6 +13,8 @@ async function isPortBusy(port: number): Promise<boolean> {
}
export async function preflight(context: InstallContext): Promise<void> {
const isReconfigure = context.packageVersion === "reconfigure";
if (process.getuid?.() !== 0) {
fail("installer must run as root");
}
@@ -26,28 +28,59 @@ export async function preflight(context: InstallContext): Promise<void> {
fail("bundled HY2XS admin is missing from install package");
}
if (await exists("/etc/hysteria/post-install.env")) {
if (!isReconfigure && (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")) {
if (!isReconfigure && (await exists(context.config.installDir))) {
fail("existing /opt/hy2xs-admin found; conflicting old state");
}
const ports = new Set([context.options.port, context.options.uiPort]);
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.options.domain && !/^[a-zA-Z0-9.-]+$/.test(context.options.domain)) {
if (context.config.domain && !/^[a-zA-Z0-9.-]+$/.test(context.config.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 (context.config.uiBindHost.includes(":")) {
fail("HY2XS UI bind host must be IPv4-only");
}
if (await isPortBusy(context.options.uiPort)) {
fail(`HY2XS admin port already appears to be in use: ${context.options.uiPort}`);
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 (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()) {
info(`warning: domain ${context.config.domain} has AAAA record; HY2XS remains IPv4-only`);
}
} catch {
// no AAAA is acceptable
}
}
if (await isPortBusy(context.config.hysteriaPort)) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`);
}
if (await isPortBusy(context.config.uiPort)) {
fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`);
}
}
+12 -2
View File
@@ -13,7 +13,17 @@ export async function smoke(context: InstallContext): Promise<void> {
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/hy2xs/hy2xs.env`;
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`;
await runVisible`test -s ${context.config.bootstrapAdminSecretPath}`;
await runVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '600'`;
await runVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`;
await runVisible`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`;
await runVisible`ss -H -ltn | grep -q '${context.config.uiBindHost}:${context.config.uiPort} '`;
if (context.config.uiBindHost === "127.0.0.1") {
await runVisible`! ss -H -ltn | grep -q '0.0.0.0:${context.config.uiPort} '`;
}
await runVisible`ss -H -lun | grep -q '0.0.0.0:${context.config.hysteriaPort} '`;
await runVisible`! ss -H -ltnu | grep -q '\[::\]'`;
await runVisible`curl -fsS --max-time 5 http://127.0.0.1:${context.config.uiPort}/ >/dev/null`;
}
+5 -2
View File
@@ -4,8 +4,11 @@ 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
UI_BIND_HOST: context.config.uiBindHost,
UI_PORT: context.config.uiPort,
INSTALL_DIR: context.config.installDir,
DATA_DIR: context.config.dataDir,
LOG_DIR: context.config.logDir
};
const hysteriaUnit = await readText(`${context.options.packageDir}/systemd/hysteria-server.service`);
+3 -3
View File
@@ -2,7 +2,7 @@ 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`;
await runVisible`cp -a ${context.options.packageDir}/ui/hy2xs-admin/. ${context.config.installDir}/`;
await runVisible`chown -R root:root ${context.config.installDir}`;
await runVisible`chmod -R go-w ${context.config.installDir}`;
}
+55 -7
View File
@@ -1,23 +1,71 @@
export type InstallOptions = {
packageDir: string;
configPath: string;
nonInteractive: boolean;
domain: string;
port: number;
sshPort: number;
skipFirewall: boolean;
skipStart: boolean;
uiPort: number;
};
export type ReconfigureOptions = {
packageDir: string;
configPath: string;
nonInteractive: boolean;
dryRun: boolean;
apply: boolean;
skipFirewall: boolean;
skipStart: boolean;
};
export type TlsMode = "acme" | "file" | "self_signed_dev";
export type RuntimeConfig = {
domain: string;
publicHost: string;
publicPort: number;
ipv6Enabled: false;
sshPort: number;
firewallEnabled: boolean;
firewallStagedApply: boolean;
uiBindHost: string;
uiPort: number;
adminUser: string;
adminInitialPassword: string;
forcePasswordChange: boolean;
tlsMode: TlsMode;
acmeEmail: string;
tlsCertPath: string;
tlsKeyPath: string;
hysteriaBindHost: string;
hysteriaPort: number;
hysteriaAuthMode: "http";
hysteriaAuthUrl: string;
hysteriaTrafficStatsHost: string;
hysteriaTrafficStatsPort: number;
hysteriaTrafficStatsSecret: string;
hysteriaObfsType: "salamander";
hysteriaObfsPassword: string;
hysteriaBandwidthUp: string;
hysteriaBandwidthDown: string;
hysteriaIgnoreClientBandwidth: boolean;
hysteriaConfigPath: string;
hysteriaVersionPolicy: string;
installDir: string;
dataDir: string;
logDir: string;
bootstrapAdminSecretPath: string;
};
export type InstallContext = {
options: InstallOptions;
config: RuntimeConfig;
packageVersion: string;
packageBuildId: string;
installDate: string;
hysteriaAuthPassword: string;
hysteriaObfsPassword: string;
hysteriaApiSecret: string;
hysteriaApiPort: number;
hysteriaVersion: string;
};
export type ReconfigureContext = {
options: ReconfigureOptions;
config: RuntimeConfig;
};