diff --git a/README.md b/README.md index 167712a..c461a24 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,15 @@ https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git На чистом 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 ./install.sh --non-interactive ``` @@ -72,8 +81,11 @@ https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git ```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 --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:` для Hysteria, `127.0.0.1:` для UI по умолчанию); diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index 041dc5d..6ec16e3 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -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 — разные миры Если нужно изменить состав 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 ``` +Проверка install-state marker: +```bash +cat /var/lib/hy2xs/install-state.json +``` + +Если `reconfigure` сообщает об отсутствии marker, нужно повторно выполнить чистый install и только потом применять runtime-изменения. + ## Auth endpoint fail checklist ```bash @@ -115,6 +133,12 @@ curl -sS \ Редактировать нужно `/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. diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md new file mode 100644 index 0000000..ac5f9d5 --- /dev/null +++ b/docs/13-production-runbook.md @@ -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. + diff --git a/orchestrator/src/cli.ts b/orchestrator/src/cli.ts index 335f201..198b671 100644 --- a/orchestrator/src/cli.ts +++ b/orchestrator/src/cli.ts @@ -1,11 +1,13 @@ import { install } from "./commands/install"; import { reconfigure } from "./commands/reconfigure"; +import { doctor } from "./commands/doctor"; import type { InstallOptions, ReconfigureOptions } from "./types/context"; function usage(): never { console.error("Usage:"); console.error(" hy2xs-orchestrator install --package-dir [--config ] [--skip-firewall] [--skip-start] [--non-interactive]"); console.error(" hy2xs-orchestrator reconfigure --package-dir [--config ] [--dry-run|--apply] [--skip-firewall] [--skip-start]"); + console.error(" hy2xs-orchestrator doctor --package-dir [--config ] [--skip-firewall] [--skip-start]"); process.exit(2); } @@ -126,6 +128,11 @@ async function main(): Promise { await reconfigure(parseReconfigureOptions(args)); return; } + if (command === "doctor") { + const options = parseReconfigureOptions(["--dry-run", ...args]); + await doctor(options); + return; + } usage(); } diff --git a/orchestrator/src/commands/doctor.ts b/orchestrator/src/commands/doctor.ts new file mode 100644 index 0000000..a108573 --- /dev/null +++ b/orchestrator/src/commands/doctor.ts @@ -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 { + 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); +} + diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 66b974e..a31e001 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -11,10 +11,35 @@ import { deployUi } from "../steps/ui"; import { installHysteria } from "../steps/hysteria"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; -import { applyFirewall } from "../steps/firewall"; +import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; import { writeBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; +const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json"; + +async function markInstallSuccessful(context: InstallContext): Promise { + 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 { + 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 { const hasSourceConfig = options.sourceConfigPath ? await exists(options.sourceConfigPath) : false; if (options.sourceConfigPath && !hasSourceConfig) { @@ -41,31 +66,40 @@ export async function install(options: InstallOptions): Promise { throw new Error("missing Hysteria lock metadata in package: hysteria.version/hysteria.url/hysteria.sha256"); } - step("preflight"); - await preflight(context); - step("system dependencies"); - await installDeps(context); - step("filesystem"); - await prepareFilesystem(context); - step("write runtime env"); - await runVisible`mkdir -p /etc/hy2xs`; - await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); - await runVisible`chown root:root ${options.runtimeConfigPath}`; - await runVisible`chmod 0600 ${options.runtimeConfigPath}`; - step("bundled UI"); - await deployUi(context); - step("Hysteria2 upstream install"); - await installHysteria(context); - step("config generation"); - await generateConfig(context); - step("systemd units"); - await deploySystemd(context); - step("firewall"); - await applyFirewall(context); - step("post-install env"); - await writePostInstallEnv(context); - step("bootstrap admin secret"); - await writeBootstrapAdminSecret(context); - step("smoke checks"); - await smoke(context); + try { + step("preflight"); + await preflight(context); + step("system dependencies"); + await installDeps(context); + step("filesystem"); + await prepareFilesystem(context); + step("write runtime env"); + await runVisible`mkdir -p /etc/hy2xs`; + await writeText(options.runtimeConfigPath, renderRuntimeEnv(config), 0o600); + await runVisible`chown root:root ${options.runtimeConfigPath}`; + await runVisible`chmod 0600 ${options.runtimeConfigPath}`; + step("bundled UI"); + await deployUi(context); + step("Hysteria2 upstream install"); + await installHysteria(context); + step("config generation"); + await generateConfig(context); + step("systemd units"); + await deploySystemd(context); + step("firewall"); + await applyFirewall(context); + step("post-install env"); + await writePostInstallEnv(context); + step("bootstrap admin secret"); + await writeBootstrapAdminSecret(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; + } } diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index 26d1ece..26ab7da 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -1,16 +1,22 @@ 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 { 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 { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; import { writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; import { runVisible } from "../lib/process"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; +const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json"; + +type InstallState = { + installed?: boolean; +}; + async function backupCurrentState(): Promise { 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`; @@ -42,6 +48,36 @@ async function rollbackCurrentState(): Promise { await runVisible`systemctl restart hysteria-server hy2xs-admin || true`; } +async function ensureInstallStateExists(): Promise { + 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 { + 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 { const configRaw = await readText(options.sourceConfigPath); const config = parseRuntimeEnv(configRaw); @@ -58,6 +94,9 @@ export async function reconfigure(options: ReconfigureOptions): Promise { step("preflight"); await preflight(context); + step("install state marker"); + await ensureInstallStateExists(); + await warnBootstrapDrift(configRaw); if (options.dryRun) { info("reconfigure dry-run: validated config and execution graph"); @@ -86,8 +125,11 @@ export async function reconfigure(options: ReconfigureOptions): Promise { await writePostInstallEnv(context); step("smoke checks"); await smoke(context); + step("finalize firewall rollback guard"); + await cancelFirewallRollback(context); } catch (error) { info("reconfigure failed, rollback in progress"); + await rollbackFirewallNow(context); await rollbackCurrentState(); throw error; } diff --git a/orchestrator/src/steps/deps.ts b/orchestrator/src/steps/deps.ts index 4ee1b5a..4465058 100644 --- a/orchestrator/src/steps/deps.ts +++ b/orchestrator/src/steps/deps.ts @@ -3,5 +3,5 @@ import { runVisible } from "../lib/process"; export async function installDeps(_context: InstallContext): Promise { 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`; } diff --git a/orchestrator/src/steps/filesystem.ts b/orchestrator/src/steps/filesystem.ts index 1ae83fd..4a19893 100644 --- a/orchestrator/src/steps/filesystem.ts +++ b/orchestrator/src/steps/filesystem.ts @@ -1,9 +1,34 @@ import type { InstallContext } from "../types/context"; import { runVisible } from "../lib/process"; +async function ensureRuntimeIdentity(user: string, expectedHome: string): Promise { + 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 { - 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`; diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts index e228451..17d2994 100644 --- a/orchestrator/src/steps/firewall.ts +++ b/orchestrator/src/steps/firewall.ts @@ -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 { + 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 { + 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}`; } diff --git a/orchestrator/src/steps/hysteria.ts b/orchestrator/src/steps/hysteria.ts index 1a26876..0d3702c 100644 --- a/orchestrator/src/steps/hysteria.ts +++ b/orchestrator/src/steps/hysteria.ts @@ -19,13 +19,17 @@ function validatePinnedVersion(value: string): void { export async function installHysteria(context: InstallContext): Promise { 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`; diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts index 24167c5..9cc3d7a 100644 --- a/orchestrator/src/steps/preflight.ts +++ b/orchestrator/src/steps/preflight.ts @@ -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 { +async function isTcpPortListening(port: number): Promise { 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 { + 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 { 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 { 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 { 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 { } } - 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`); } } diff --git a/tools/build/lib/deps.sh b/tools/build/lib/deps.sh index 260cb00..36d9b52 100644 --- a/tools/build/lib/deps.sh +++ b/tools/build/lib/deps.sh @@ -89,10 +89,13 @@ go_version() { ensure_go() { local managed="$TOOLCHAIN_DIR/go/bin/go" + local go_mode="" if [ -x "$managed" ] && [ "$(go_version "$managed")" = "$GO_REQUIRED" ]; then GO_BIN="$managed" + go_mode="managed" elif command -v go >/dev/null 2>&1 && [ "$(go_version "$(command -v go)")" = "$GO_REQUIRED" ]; then GO_BIN="$(command -v go)" + go_mode="global" else log_info "Installing Go $GO_REQUIRED into $TOOLCHAIN_DIR/go" mkdir -p "$TOOLCHAIN_DIR/downloads" @@ -102,10 +105,15 @@ ensure_go() { rm -rf "$TOOLCHAIN_DIR/go" tar -C "$TOOLCHAIN_DIR" -xzf "$archive" GO_BIN="$managed" + go_mode="managed" fi 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 GOTOOLCHAIN=local [ "$(go_version "$GO_BIN")" = "$GO_REQUIRED" ] || fail "Go version mismatch: required $GO_REQUIRED, got $($GO_BIN version)"