Продакшн-фиксы 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
+12
View File
@@ -55,6 +55,15 @@ https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git
На чистом Debian 12 target нужно распаковать архив и запустить от root: На чистом Debian 12 target нужно распаковать архив и запустить от root:
Обязательные системные зависимости target-хоста:
```sh
apt-get update
apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd
```
`sudo` является обязательной зависимостью target-хоста. Он используется не для интерактивной установки, а для smoke-проверок прав доступа от имени runtime-пользователей.
```sh ```sh
./install.sh --non-interactive ./install.sh --non-interactive
``` ```
@@ -72,8 +81,11 @@ https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git
```sh ```sh
hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --dry-run hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --dry-run
hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --apply hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --apply
hy2xs-orchestrator doctor --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env
``` ```
`HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` — install-only bootstrap-поля. Их изменение в `/etc/hy2xs/hy2xs.env` после установки не ротирует существующие credentials в SQLite автоматически.
Ключевые инварианты: Ключевые инварианты:
- только IPv4 (`0.0.0.0:<port>` для Hysteria, `127.0.0.1:<ui_port>` для UI по умолчанию); - только IPv4 (`0.0.0.0:<port>` для Hysteria, `127.0.0.1:<ui_port>` для UI по умолчанию);
+24
View File
@@ -6,6 +6,17 @@
## Что должен помнить оператор ## Что должен помнить оператор
### 0. Target prerequisites обязательны
На target-хосте до запуска install должны быть доступны системные зависимости:
```bash
apt-get update
apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd
```
`sudo` обязателен: используется smoke-проверками прав от имени runtime-пользователей (`hysteria`, `hy2xs-admin`).
### 1. Builder и target — разные миры ### 1. Builder и target — разные миры
Если нужно изменить состав install package, это делается в локальном builder layer, а не на target server. Если нужно изменить состав install package, это делается в локальном builder layer, а не на target server.
@@ -60,6 +71,13 @@ journalctl -u hysteria-server -n 100 --no-pager
journalctl -u hy2xs-admin -n 100 --no-pager journalctl -u hy2xs-admin -n 100 --no-pager
``` ```
Проверка install-state marker:
```bash
cat /var/lib/hy2xs/install-state.json
```
Если `reconfigure` сообщает об отсутствии marker, нужно повторно выполнить чистый install и только потом применять runtime-изменения.
## Auth endpoint fail checklist ## Auth endpoint fail checklist
```bash ```bash
@@ -115,6 +133,12 @@ curl -sS \
Редактировать нужно `/etc/hy2xs/hy2xs.env` и затем запускать `reconfigure --dry-run/--apply`. Редактировать нужно `/etc/hy2xs/hy2xs.env` и затем запускать `reconfigure --dry-run/--apply`.
### Изменили bootstrap-поля, но пароль admin не сменился
Это ожидаемо.
`HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` используются только как bootstrap-данные при первичной установке.
Для ротации существующих credentials нужен отдельный flow на уровне account-management.
## Правила эксплуатации ## Правила эксплуатации
1. Не править сервер как будто на нём есть builder. 1. Не править сервер как будто на нём есть builder.
+91
View File
@@ -0,0 +1,91 @@
# HY2XS production runbook
## 1. Supported target
- clean Debian 12 amd64
- single host install profile
- IPv4-only runtime model
## 2. Required prerequisites
```bash
apt-get update
apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd
```
`sudo` обязателен для permission smoke-checks от имени runtime-пользователей.
## 3. Required open ports
- UDP `${HY2XS_HYSTERIA_PORT}`
- TCP `${HY2XS_UI_PORT}` (обычно localhost bind)
- TCP `${HY2XS_SSH_PORT}`
- TCP 80/443 для ACME (в зависимости от типа challenge)
## 4. Clean host assumptions
- нет legacy-конфликта по runtime-users (`hysteria`, `hy2xs-admin`)
- нет конфликтующего не-HY2XS nftables entrypoint
- install запускается от root
## 5. Install command
```bash
./install.sh --non-interactive
```
## 6. Post-install verification
```bash
systemctl status hysteria-server
systemctl status hy2xs-admin
ss -H -lun | grep ':443'
ss -H -ltn | grep ':8080'
nft list ruleset
cat /var/lib/hy2xs/install-state.json
```
## 7. Permission verification
```bash
ls -l /etc/hy2xs/hy2xs.env
ls -l /etc/hysteria/config.yaml
sudo -u hysteria test -r /etc/hysteria/config.yaml
sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml
sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml
sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env
```
## 8. Firewall recovery
Если `install`/`reconfigure` падают после firewall apply:
- rollback guard не должен отменяться до успешного smoke;
- для recovery использовать вывод оркестратора и перезапускать apply только после устранения root-cause.
## 9. Reconfigure flow
```bash
hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --dry-run
hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env --apply
```
## 10. Admin bootstrap credentials
- `HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` — install-only bootstrap поля.
- изменение значений в `/etc/hy2xs/hy2xs.env` после install не выполняет автоматическую ротацию существующих credentials.
## 11. IPv4/IPv6 policy
- HY2XS работает в IPv4-only режиме.
- если IPv6 включён на хосте/провайдере — это вне baseline и должно быть отдельно управляемо оператором.
## 12. Validation command
```bash
hy2xs-orchestrator doctor --package-dir /usr/local/lib/hy2xs/package --config /etc/hy2xs/hy2xs.env
```
Команда выполняет preflight + smoke как post-install/post-reboot validation.
+7
View File
@@ -1,11 +1,13 @@
import { install } from "./commands/install"; import { install } from "./commands/install";
import { reconfigure } from "./commands/reconfigure"; import { reconfigure } from "./commands/reconfigure";
import { doctor } from "./commands/doctor";
import type { InstallOptions, ReconfigureOptions } from "./types/context"; import type { InstallOptions, ReconfigureOptions } from "./types/context";
function usage(): never { function usage(): never {
console.error("Usage:"); console.error("Usage:");
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start] [--non-interactive]"); 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]"); console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-start]");
console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-start]");
process.exit(2); process.exit(2);
} }
@@ -126,6 +128,11 @@ async function main(): Promise<void> {
await reconfigure(parseReconfigureOptions(args)); await reconfigure(parseReconfigureOptions(args));
return; return;
} }
if (command === "doctor") {
const options = parseReconfigureOptions(["--dry-run", ...args]);
await doctor(options);
return;
}
usage(); usage();
} }
+28
View File
@@ -0,0 +1,28 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { readText } from "../lib/fs";
import { step } from "../lib/log";
import { parseRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight";
import { smoke } from "../steps/smoke";
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
export async function doctor(options: ReconfigureOptions): Promise<void> {
const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw);
const context: ReconfigureContext = {
mode: "reconfigure",
options: { ...options, dryRun: true, apply: false },
config,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
installDate: new Date().toISOString(),
hysteriaVersion: await readInstalledHysteriaVersion()
};
step("doctor preflight");
await preflight(context);
step("doctor smoke");
await smoke(context);
}
+62 -28
View File
@@ -11,10 +11,35 @@ import { deployUi } from "../steps/ui";
import { installHysteria } from "../steps/hysteria"; import { installHysteria } from "../steps/hysteria";
import { generateConfig } from "../steps/config"; import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd"; import { deploySystemd } from "../steps/systemd";
import { applyFirewall } from "../steps/firewall"; import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall";
import { writeBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { writeBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke"; import { smoke } from "../steps/smoke";
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
async function markInstallSuccessful(context: InstallContext): Promise<void> {
await runVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`;
const state = JSON.stringify(
{
installed: true,
version: context.packageVersion,
build_id: context.packageBuildId,
installed_at: new Date().toISOString()
},
null,
2
);
await writeText(INSTALL_STATE_PATH, `${state}\n`, 0o644);
await runVisible`chown root:root ${INSTALL_STATE_PATH}`;
}
async function rollbackFailedInstall(context: InstallContext): Promise<void> {
await rollbackFirewallNow(context);
await runVisible`systemctl stop hysteria-server hy2xs-admin || true`;
await runVisible`systemctl disable hysteria-server hy2xs-admin || true`;
await runVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`;
}
export async function install(options: InstallOptions): Promise<void> { export async function install(options: InstallOptions): Promise<void> {
const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false; const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false;
if (options.sourceConfigPath && !hasSourceConfig) { if (options.sourceConfigPath && !hasSourceConfig) {
@@ -41,31 +66,40 @@ export async function install(options: InstallOptions): Promise<void> {
throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256"); throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256");
} }
step("preflight"); try {
await preflight(context); step("preflight");
step("system dependencies"); await preflight(context);
await installDeps(context); step("system dependencies");
step("filesystem"); await installDeps(context);
await prepareFilesystem(context); step("filesystem");
step("write runtime env"); await prepareFilesystem(context);
await runVisible`mkdir -p /etc/hy2xs`; step("write runtime env");
await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); await runVisible`mkdir -p /etc/hy2xs`;
await runVisible`chown root:root ${options.runtimeConfigPath}`; await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600);
await runVisible`chmod 0600 ${options.runtimeConfigPath}`; await runVisible`chown root:root ${options.runtimeConfigPath}`;
step("bundled UI"); await runVisible`chmod 0600 ${options.runtimeConfigPath}`;
await deployUi(context); step("bundled UI");
step("Hysteria2 upstream install"); await deployUi(context);
await installHysteria(context); step("Hysteria2 upstream install");
step("config generation"); await installHysteria(context);
await generateConfig(context); step("config generation");
step("systemd units"); await generateConfig(context);
await deploySystemd(context); step("systemd units");
step("firewall"); await deploySystemd(context);
await applyFirewall(context); step("firewall");
step("post-install env"); await applyFirewall(context);
await writePostInstallEnv(context); step("post-install env");
step("bootstrap admin secret"); await writePostInstallEnv(context);
await writeBootstrapAdminSecret(context); step("bootstrap admin secret");
step("smoke checks"); await writeBootstrapAdminSecret(context);
await smoke(context); step("smoke checks");
await smoke(context);
step("finalize firewall rollback guard");
await cancelFirewallRollback(context);
step("mark install successful");
await markInstallSuccessful(context);
} catch (error) {
await rollbackFailedInstall(context);
throw error;
}
} }
+44 -2
View File
@@ -1,16 +1,22 @@
import type { ReconfigureContext, ReconfigureOptions } from "../types/context"; import type { ReconfigureContext, ReconfigureOptions } from "../types/context";
import { readText, writeText } from "../lib/fs"; import { exists, readText, writeText } from "../lib/fs";
import { info, step } from "../lib/log"; import { info, step } from "../lib/log";
import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env";
import { preflight } from "../steps/preflight"; import { preflight } from "../steps/preflight";
import { generateConfig } from "../steps/config"; import { generateConfig } from "../steps/config";
import { deploySystemd } from "../steps/systemd"; import { deploySystemd } from "../steps/systemd";
import { applyFirewall } from "../steps/firewall"; import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall";
import { writePostInstallEnv } from "../steps/env"; import { writePostInstallEnv } from "../steps/env";
import { smoke } from "../steps/smoke"; import { smoke } from "../steps/smoke";
import { runVisible } from "../lib/process"; import { runVisible } from "../lib/process";
import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta";
const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json";
type InstallState = {
installed?: boolean;
};
async function backupCurrentState(): Promise<void> { async function backupCurrentState(): Promise<void> {
await runVisible`mkdir -p /etc/hy2xs/backups`; 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/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`;
@@ -42,6 +48,36 @@ async function rollbackCurrentState(): Promise<void> {
await runVisible`systemctl restart hysteria-server hy2xs-admin || true`; await runVisible`systemctl restart hysteria-server hy2xs-admin || true`;
} }
async function ensureInstallStateExists(): Promise<void> {
if (!(await exists(INSTALL_STATE_PATH))) {
throw new Error(`install state marker is missing: ${INSTALL_STATE_PATH}. Run install first.`);
}
const raw = await readText(INSTALL_STATE_PATH);
let parsed: InstallState;
try {
parsed = JSON.parse(raw) as InstallState;
} catch {
throw new Error(`invalid install state marker format: ${INSTALL_STATE_PATH}`);
}
if (!parsed.installed) {
throw new Error(`install state marker does not indicate successful installation: ${INSTALL_STATE_PATH}`);
}
}
async function warnBootstrapDrift(nextConfigRaw: string): Promise<void> {
if (!(await exists("/etc/hy2xs/hy2xs.env"))) {
return;
}
const prev = parseRuntimeEnv(await readText("/etc/hy2xs/hy2xs.env"));
const next = parseRuntimeEnv(nextConfigRaw);
if (prev.adminInitialPassword !== next.adminInitialPassword || prev.adminConPass !== next.adminConPass) {
info("warning: Admin bootstrap fields are install-only and will not rotate existing credentials.");
info("warning: Use a dedicated password rotation flow in application/account layer.");
}
}
export async function reconfigure(options: ReconfigureOptions): Promise<void> { export async function reconfigure(options: ReconfigureOptions): Promise<void> {
const configRaw = await readText(options.sourceConfigPath); const configRaw = await readText(options.sourceConfigPath);
const config = parseRuntimeEnv(configRaw); const config = parseRuntimeEnv(configRaw);
@@ -58,6 +94,9 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
step("preflight"); step("preflight");
await preflight(context); await preflight(context);
step("install state marker");
await ensureInstallStateExists();
await warnBootstrapDrift(configRaw);
if (options.dryRun) { if (options.dryRun) {
info("reconfigure dry-run: validated config and execution graph"); info("reconfigure dry-run: validated config and execution graph");
@@ -86,8 +125,11 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
await writePostInstallEnv(context); await writePostInstallEnv(context);
step("smoke checks"); step("smoke checks");
await smoke(context); await smoke(context);
step("finalize firewall rollback guard");
await cancelFirewallRollback(context);
} catch (error) { } catch (error) {
info("reconfigure failed, rollback in progress"); info("reconfigure failed, rollback in progress");
await rollbackFirewallNow(context);
await rollbackCurrentState(); await rollbackCurrentState();
throw error; throw error;
} }
+1 -1
View File
@@ -3,5 +3,5 @@ import { runVisible } from "../lib/process";
export async function installDeps(_context: InstallContext): Promise<void> { export async function installDeps(_context: InstallContext): Promise<void> {
await runVisible`apt-get update`; 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 type { InstallContext } from "../types/context";
import { runVisible } from "../lib/process"; 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> { 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 ensureRuntimeIdentity("hysteria", "/var/lib/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("hy2xs-admin", context.config.dataDir);
await runVisible`install -d -m 0700 -o root -g root /etc/hy2xs`; 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 0755 -o root -g root /etc/hysteria`;
await runVisible`install -d -m 0750 -o hysteria -g hysteria /var/lib/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 { fail, info } from "../lib/log";
import { runVisible } from "../lib/process"; 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 { function stripNftComments(content: string): string {
return content return content
.split(/\r?\n/) .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)`; 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) { if (context.config.firewallStagedApply) {
await runVisible`systemctl stop hy2xs-fw-rollback || true`; await runVisible`systemctl stop hy2xs-fw-rollback || true`;
await runVisible`systemctl reset-failed 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> { export async function installHysteria(context: InstallContext): Promise<void> {
validatePinnedVersion(context.hysteriaTargetVersion); 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}`; try {
await runVisible`test -s ${tmp}`; await runVisible`curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location ${context.hysteriaArtifactUrl} -o ${tmp}`;
await runVisible`printf '%s %s\n' ${context.hysteriaArtifactSha256} ${tmp} | sha256sum -c -`; await runVisible`test -s ${tmp}`;
await runVisible`install -m 0755 ${tmp} /usr/local/bin/hysteria`; await runVisible`printf '%s %s\n' ${context.hysteriaArtifactSha256} ${tmp} | sha256sum -c -`;
await runVisible`rm -f ${tmp}`; 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`; await runVisible`test -x /usr/local/bin/hysteria`;
const versionOutput = await run`/usr/local/bin/hysteria version`; 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 { fail, info } from "../lib/log";
import { run } from "../lib/process"; import { run } from "../lib/process";
async function isPortBusy(port: number): Promise<boolean> { async function isTcpPortListening(port: number): Promise<boolean> {
try { 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}`)); return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
} catch { } catch {
return false; return false;
@@ -28,6 +37,12 @@ export async function preflight(context: RuntimeContext): Promise<void> {
fail("installer must run as root"); 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"); const osRelease = await readText("/etc/os-release");
if (!/^ID=debian$/m.test(osRelease) || !/^VERSION_ID="?12"?$/m.test(osRelease)) { if (!/^ID=debian$/m.test(osRelease) || !/^VERSION_ID="?12"?$/m.test(osRelease)) {
fail("HY2XS baseline supports only clean Debian 12"); 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"); 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") { if (context.config.hysteriaBindHost !== "0.0.0.0") {
fail("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 for production profile"); 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") { if (!isReconfigure && context.config.tlsMode === "acme") {
const acmeChallengePort = context.config.acmeType === "http" ? 80 : 443; 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}`); 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 hysteriaUdpBusy = await isUdpPortListening(context.config.hysteriaPort);
const uiPortBusy = await isPortBusy(context.config.uiPort); const uiTcpBusy = await isTcpPortListening(context.config.uiPort);
if (!isReconfigure) { if (!isReconfigure) {
if (hysteriaPortBusy) { if (hysteriaUdpBusy) {
fail(`Hysteria UDP/TCP port already appears to be in use: ${context.config.hysteriaPort}`); 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}`); fail(`HY2XS admin port already appears to be in use: ${context.config.uiPort}`);
} }
return; 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`); 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`); fail(`HY2XS admin port ${context.config.uiPort} is occupied by a non-HY2XS process`);
} }
} }
+9 -1
View File
@@ -89,10 +89,13 @@ go_version() {
ensure_go() { ensure_go() {
local managed="$TOOLCHAIN_DIR/go/bin/go" local managed="$TOOLCHAIN_DIR/go/bin/go"
local go_mode=""
if [ -x "$managed" ] && [ "$(go_version "$managed")" = "$GO_REQUIRED" ]; then if [ -x "$managed" ] && [ "$(go_version "$managed")" = "$GO_REQUIRED" ]; then
GO_BIN="$managed" GO_BIN="$managed"
go_mode="managed"
elif command -v go >/dev/null 2>&1 && [ "$(go_version "$(command -v go)")" = "$GO_REQUIRED" ]; then elif command -v go >/dev/null 2>&1 && [ "$(go_version "$(command -v go)")" = "$GO_REQUIRED" ]; then
GO_BIN="$(command -v go)" GO_BIN="$(command -v go)"
go_mode="global"
else else
log_info "Installing Go $GO_REQUIRED into $TOOLCHAIN_DIR/go" log_info "Installing Go $GO_REQUIRED into $TOOLCHAIN_DIR/go"
mkdir -p "$TOOLCHAIN_DIR/downloads" mkdir -p "$TOOLCHAIN_DIR/downloads"
@@ -102,10 +105,15 @@ ensure_go() {
rm -rf "$TOOLCHAIN_DIR/go" rm -rf "$TOOLCHAIN_DIR/go"
tar -C "$TOOLCHAIN_DIR" -xzf "$archive" tar -C "$TOOLCHAIN_DIR" -xzf "$archive"
GO_BIN="$managed" GO_BIN="$managed"
go_mode="managed"
fi fi
export GO_BIN export GO_BIN
export GOROOT="$TOOLCHAIN_DIR/go" if [ "$go_mode" = "managed" ]; then
export GOROOT="$TOOLCHAIN_DIR/go"
else
unset GOROOT || true
fi
export PATH="$(dirname "$GO_BIN"):$PATH" export PATH="$(dirname "$GO_BIN"):$PATH"
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
[ "$(go_version "$GO_BIN")" = "$GO_REQUIRED" ] || fail "Go version mismatch: required $GO_REQUIRED, got $($GO_BIN version)" [ "$(go_version "$GO_BIN")" = "$GO_REQUIRED" ] || fail "Go version mismatch: required $GO_REQUIRED, got $($GO_BIN version)"