Реализован 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
+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}`;
}