Продакшн-фиксы install/reconfigure: rollback, firewall lifecycle, preflight и runbook

This commit is contained in:
2026-05-01 21:14:24 +05:00
parent 5ed99eac7f
commit d393308216
13 changed files with 379 additions and 51 deletions
+1 -1
View File
@@ -3,5 +3,5 @@ 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`;
await runVisible`apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd`;
}
+27 -2
View File
@@ -1,9 +1,34 @@
import type { InstallContext } from "../types/context";
import { runVisible } from "../lib/process";
async function ensureRuntimeIdentity(user: string, expectedHome: string): Promise<void> {
const checkCmd = `
if id -u ${user} >/dev/null 2>&1; then
shell="$(getent passwd ${user} | cut -d: -f7)"
home="$(getent passwd ${user} | cut -d: -f6)"
group="$(id -gn ${user})"
if [ "$shell" != "/usr/sbin/nologin" ] && [ "$shell" != "/bin/false" ]; then
echo "existing user '${user}' has unsupported shell: $shell" >&2
exit 1
fi
if [ "$group" != "${user}" ]; then
echo "existing user '${user}' must have primary group '${user}', got: $group" >&2
exit 1
fi
if [ "$home" != "${expectedHome}" ]; then
echo "existing user '${user}' has unexpected home: $home (expected ${expectedHome})" >&2
exit 1
fi
else
useradd --system --home ${expectedHome} --shell /usr/sbin/nologin ${user}
fi
`;
await runVisible`${checkCmd}`;
}
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`id -u hy2xs-admin >/dev/null 2>&1 || useradd --system --home ${context.config.dataDir} --shell /usr/sbin/nologin hy2xs-admin`;
await ensureRuntimeIdentity("hysteria", "/var/lib/hysteria");
await ensureRuntimeIdentity("hy2xs-admin", context.config.dataDir);
await runVisible`install -d -m 0700 -o root -g root /etc/hy2xs`;
await runVisible`install -d -m 0755 -o root -g root /etc/hysteria`;
await runVisible`install -d -m 0750 -o hysteria -g hysteria /var/lib/hysteria`;
+35 -1
View File
@@ -3,6 +3,16 @@ import { exists, readText, renderTemplate, writeText } from "../lib/fs";
import { fail, info } from "../lib/log";
import { runVisible } from "../lib/process";
const FW_BACKUP_FILES = [
"/etc/nftables.conf.hy2xs.bak",
"/etc/nftables.conf.candidate",
"/etc/nftables.d/hy2xs.nft.bak",
"/etc/nftables.d/hy2xs.nft.candidate",
"/etc/nftables.d/hy2xs.nft.existed",
"/etc/nftables.d/hy2xs.nft.include.existed",
"/etc/nftables.d/nftables.conf.existed"
].join(" ");
function stripNftComments(content: string): string {
return content
.split(/\r?\n/)
@@ -93,10 +103,34 @@ include "/etc/nftables.d/hy2xs.nft"
await runVisible`ss -H -ltn | grep -q ':${context.config.sshPort} ' || (echo 'ssh port check failed' >&2; exit 1)`;
info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks");
}
export async function cancelFirewallRollback(context: RuntimeContext): Promise<void> {
if (!context.config.firewallEnabled || context.options.skipFirewall) {
return;
}
if (context.config.firewallStagedApply) {
await runVisible`systemctl stop hy2xs-fw-rollback || true`;
await runVisible`systemctl reset-failed hy2xs-fw-rollback || true`;
}
await runVisible`rm -f /etc/nftables.conf.hy2xs.bak /etc/nftables.conf.candidate /etc/nftables.d/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft.existed /etc/nftables.d/hy2xs.nft.include.existed /etc/nftables.d/nftables.conf.existed`;
await runVisible`rm -f ${FW_BACKUP_FILES}`;
}
export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> {
if (!context.config.firewallEnabled || context.options.skipFirewall) {
return;
}
if (context.config.firewallStagedApply) {
await runVisible`systemctl stop hy2xs-fw-rollback || true`;
await runVisible`systemctl reset-failed hy2xs-fw-rollback || true`;
}
await runVisible`if [ -f /etc/nftables.d/nftables.conf.existed ]; then cp -a /etc/nftables.conf.hy2xs.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`;
await runVisible`if [ -f /etc/nftables.d/hy2xs.nft.existed ]; then cp -a /etc/nftables.d/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`;
await runVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`;
await runVisible`rm -f ${FW_BACKUP_FILES}`;
}
+10 -6
View File
@@ -19,13 +19,17 @@ function validatePinnedVersion(value: string): void {
export async function installHysteria(context: InstallContext): Promise<void> {
validatePinnedVersion(context.hysteriaTargetVersion);
const tmp = "/tmp/hy2xs-hysteria-linux-amd64";
const tmpDir = await run`mktemp -d`;
const tmp = `${tmpDir.trim()}/hysteria-linux-amd64`;
await runVisible`curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location ${context.hysteriaArtifactUrl} -o ${tmp}`;
await runVisible`test -s ${tmp}`;
await runVisible`printf '%s %s\n' ${context.hysteriaArtifactSha256} ${tmp} | sha256sum -c -`;
await runVisible`install -m 0755 ${tmp} /usr/local/bin/hysteria`;
await runVisible`rm -f ${tmp}`;
try {
await runVisible`curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location ${context.hysteriaArtifactUrl} -o ${tmp}`;
await runVisible`test -s ${tmp}`;
await runVisible`printf '%s %s\n' ${context.hysteriaArtifactSha256} ${tmp} | sha256sum -c -`;
await runVisible`install -m 0755 -o root -g root ${tmp} /usr/local/bin/hysteria`;
} finally {
await runVisible`rm -rf ${tmpDir.trim()}`;
}
await runVisible`test -x /usr/local/bin/hysteria`;
const versionOutput = await run`/usr/local/bin/hysteria version`;
+29 -10
View File
@@ -3,9 +3,18 @@ import { exists, readText } from "../lib/fs";
import { fail, info } from "../lib/log";
import { run } from "../lib/process";
async function isPortBusy(port: number): Promise<boolean> {
async function isTcpPortListening(port: number): Promise<boolean> {
try {
const output = await run`ss -H -lntu`;
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;
@@ -28,6 +37,12 @@ export async function preflight(context: RuntimeContext): Promise<void> {
fail("installer must run as root");
}
try {
await run`command -v sudo >/dev/null 2>&1`;
} catch {
fail("sudo is required for installer smoke checks. Install it with: apt-get update && apt-get install -y sudo");
}
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");
@@ -67,6 +82,10 @@ export async function preflight(context: RuntimeContext): Promise<void> {
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");
}
@@ -77,7 +96,7 @@ export async function preflight(context: RuntimeContext): Promise<void> {
if (!isReconfigure && context.config.tlsMode === "acme") {
const acmeChallengePort = context.config.acmeType === "http" ? 80 : 443;
if (await isPortBusy(acmeChallengePort)) {
if (await isTcpPortListening(acmeChallengePort)) {
fail(`ACME ${context.config.acmeType}-challenge port is already in use: ${acmeChallengePort}`);
}
}
@@ -101,24 +120,24 @@ export async function preflight(context: RuntimeContext): Promise<void> {
}
}
const hysteriaPortBusy = await isPortBusy(context.config.hysteriaPort);
const uiPortBusy = await isPortBusy(context.config.uiPort);
const hysteriaUdpBusy = await isUdpPortListening(context.config.hysteriaPort);
const uiTcpBusy = await isTcpPortListening(context.config.uiPort);
if (!isReconfigure) {
if (hysteriaPortBusy) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`);
if (hysteriaUdpBusy) {
fail(`Hysteria UDP port already appears to be in use: ${context.config.hysteriaPort}`);
}
if (uiPortBusy) {
if (uiTcpBusy) {
fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`);
}
return;
}
if (hysteriaPortBusy && !(await isUnitActive("hysteria-server"))) {
if (hysteriaUdpBusy && !(await isUnitActive("hysteria-server"))) {
fail(`Hysteria port ${context.config.hysteriaPort} is occupied by a non-HY2XS process`);
}
if (uiPortBusy && !(await isUnitActive("hy2xs-admin"))) {
if (uiTcpBusy && !(await isUnitActive("hy2xs-admin"))) {
fail(`HY2XS admin port ${context.config.uiPort} is occupied by a non-HY2XS process`);
}
}