Реализован production-hardening по fix1: env/reconfigure, IPv4-only, TLS, secrets, firewall, docs
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user