diff --git a/orchestrator/src/commands/diagnostics.ts b/orchestrator/src/commands/diagnostics.ts index 9e34ece..af8a318 100644 --- a/orchestrator/src/commands/diagnostics.ts +++ b/orchestrator/src/commands/diagnostics.ts @@ -1,6 +1,6 @@ import type { CommonOptions } from "../types/context"; import { info, setOperationContext } from "../lib/log"; -import { run } from "../lib/process"; +import { runMutating } from "../lib/process"; import { redactEnv, redactYaml } from "../lib/redaction"; function shellEscapeSingleQuotes(value: string): string { @@ -13,20 +13,20 @@ export async function diagnosticsCollect(_options: CommonOptions): Promise const outDir = `/var/log/hy2xs/diagnostics/${opId}`; const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`; - await run`mkdir -p ${outDir}`; + await runMutating`mkdir -p ${outDir}`; - await run`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`; - await run`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`; - await run`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`; - await run`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`; - await run`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`; - await run`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`; - await run`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`; - await run`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`; - await run`sh -c ${`cp -a /etc/hy2xs/hy2xs.env '${shellEscapeSingleQuotes(`${outDir}/hy2xs.env`)}' 2>/dev/null || true`}`; - await run`sh -c ${`cp -a /etc/hysteria/config.yaml '${shellEscapeSingleQuotes(`${outDir}/hysteria-config.yaml`)}' 2>/dev/null || true`}`; - await run`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`; - await run`sh -c ${`ss -ltnup > '${shellEscapeSingleQuotes(`${outDir}/ss-ltnup.txt`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`; + await runMutating`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`; + await runMutating`sh -c ${`cp -a /etc/hy2xs/hy2xs.env '${shellEscapeSingleQuotes(`${outDir}/hy2xs.env`)}' 2>/dev/null || true`}`; + await runMutating`sh -c ${`cp -a /etc/hysteria/config.yaml '${shellEscapeSingleQuotes(`${outDir}/hysteria-config.yaml`)}' 2>/dev/null || true`}`; + await runMutating`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`; + await runMutating`sh -c ${`ss -ltnup > '${shellEscapeSingleQuotes(`${outDir}/ss-ltnup.txt`)}' 2>&1 || true`}`; try { const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text(); @@ -49,7 +49,7 @@ export async function diagnosticsCollect(_options: CommonOptions): Promise // noop } - await run`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`; + await runMutating`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`; info(`diagnostics bundle collected: ${archive}`); } diff --git a/orchestrator/src/commands/doctor.ts b/orchestrator/src/commands/doctor.ts index 88247bf..5a16149 100644 --- a/orchestrator/src/commands/doctor.ts +++ b/orchestrator/src/commands/doctor.ts @@ -5,7 +5,7 @@ import { parseRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { smoke } from "../steps/smoke"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; -import { run } from "../lib/process"; +import { runReadOnly } from "../lib/process"; function hasPermitOpenForLocalUi(value: string): boolean { const normalized = value.trim().toLowerCase(); @@ -24,7 +24,7 @@ async function checkSshForwardingForLocalUi(uiBindHost: string): Promise { } try { - const sshdConfigText = await run`sshd -T`; + const sshdConfigText = await runReadOnly`sshd -T`; const lines = sshdConfigText.split(/\r?\n/); const effective = new Map(); for (const line of lines) { diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 87c7add..5b162bb 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -1,6 +1,6 @@ import type { InstallContext, InstallOptions } from "../types/context"; import { fileExists, readText, writeText, writeTextAtomic } from "../lib/fs"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; import { info, setOperationContext, step, stepDone } from "../lib/log"; import { readPackageValue } from "../lib/packageMeta"; import { @@ -41,19 +41,30 @@ type InstallPhase = | "installed"; /** - * Что именно текущая операция успела изменить на сервере. + * Что текущая операция МОГЛА изменить на сервере. + * + * Формулировка важна. Флаг «шаг успешно завершился» отвечает не на тот вопрос: + * `apt-get install` умеет распаковать половину пакетов и упасть, и хост уже + * изменён, хотя шаг не закончился. Поэтому каждый флаг взводится ПЕРЕД + * мутирующим вызовом, а не после него, и читается как «сюда мы уже могли + * влезть». * * Rollback обязан опираться на это, а не на текст ошибки: остановить чужой * работающий сервис только потому, что в сообщении встретилось слово * "firewall", — недопустимо. */ type OperationOwnership = { + /** install-state.json уже создан: сам по себе делает хост изменённым. */ stateWritten: boolean; - depsInstalled: boolean; - filesystemPrepared: boolean; - unitsDeployed: boolean; + depsTouched: boolean; + filesystemTouched: boolean; + uiTouched: boolean; + hysteriaTouched: boolean; + configTouched: boolean; + unitsTouched: boolean; firewallTouched: boolean; - postInstallWritten: boolean; + postInstallTouched: boolean; + bootstrapSecretTouched: boolean; servicesStarted: boolean; }; @@ -68,11 +79,15 @@ type FailureKind = function newOwnership(): OperationOwnership { return { stateWritten: false, - depsInstalled: false, - filesystemPrepared: false, - unitsDeployed: false, + depsTouched: false, + filesystemTouched: false, + uiTouched: false, + hysteriaTouched: false, + configTouched: false, + unitsTouched: false, firewallTouched: false, - postInstallWritten: false, + postInstallTouched: false, + bootstrapSecretTouched: false, servicesStarted: false }; } @@ -108,9 +123,9 @@ async function writeInstallState( repairHint: phase === "installed" ? undefined : REPAIR_HINT }); - await runVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`; + await runMutatingVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`; await writeText(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, 0o644); - await runVisible`chown root:root ${INSTALL_STATE_PATH}`; + await runMutatingVisible`chown root:root ${INSTALL_STATE_PATH}`; } async function advanceInstallState( @@ -124,8 +139,14 @@ async function advanceInstallState( } /** - * Классификация опирается на то, что операция реально успела применить. - * `fatal_pre_apply` по определению означает «на сервере ничего не изменено». + * Классификация опирается на то, к чему операция уже могла прикоснуться. + * `fatal_pre_apply` по определению означает «на сервере ничего не изменено», + * поэтому в него нельзя попасть после ЛЮБОГО взведённого флага — включая + * `stateWritten`: записанный /var/lib/hy2xs/install-state.json это уже + * изменение хоста, которое переживёт неудачную установку. + * + * Порядок веток — от самой поздней стадии к самой ранней: она точнее + * описывает, что именно чинить. */ export function classifyFailure(ownership: OperationOwnership, phase: InstallPhase): FailureKind { if (ownership.servicesStarted) { @@ -133,13 +154,21 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha ? "smoke_readiness_timeout" : "service_start_failure"; } - if (ownership.postInstallWritten) { + if (ownership.postInstallTouched || ownership.bootstrapSecretTouched) { return "postinstall_validation_failure"; } if (ownership.firewallTouched) { return "firewall_connectivity_failure"; } - if (ownership.unitsDeployed || ownership.filesystemPrepared || ownership.depsInstalled) { + if ( + ownership.unitsTouched || + ownership.configTouched || + ownership.hysteriaTouched || + ownership.uiTouched || + ownership.filesystemTouched || + ownership.depsTouched || + ownership.stateWritten + ) { return "fatal_post_apply"; } return "fatal_pre_apply"; @@ -164,14 +193,14 @@ async function rollbackFailedInstall( await rollbackFirewallNow(context); } - if (!ownership.unitsDeployed) { + if (!ownership.unitsTouched) { info("rollback: systemd units were not deployed by this operation, leaving services untouched"); return; } - 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`; + await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`; + await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`; + await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; } export async function install(options: InstallOptions): Promise { @@ -215,8 +244,8 @@ export async function install(options: InstallOptions): Promise { await advanceInstallState(context, ownership, "preflight_ok"); phase = "preflight_ok"; step("system dependencies"); + ownership.depsTouched = true; await installDeps(context); - ownership.depsInstalled = true; stepDone("system dependencies"); step("preflight capabilities"); await preflight(context, { requireCapabilities: true, cleanHostPhase: "install" }); @@ -224,13 +253,13 @@ export async function install(options: InstallOptions): Promise { await advanceInstallState(context, ownership, "deps_ok"); phase = "deps_ok"; step("filesystem"); + ownership.filesystemTouched = true; await prepareFilesystem(context); - ownership.filesystemPrepared = true; stepDone("filesystem"); await advanceInstallState(context, ownership, "filesystem_ready"); phase = "filesystem_ready"; step("write runtime env"); - await runVisible`mkdir -p /etc/hy2xs`; + await runMutatingVisible`mkdir -p /etc/hy2xs`; await writeTextAtomic(options.runtimeConfigPath, renderRuntimeEnv(config), { mode: 0o600, owner: "root", @@ -240,23 +269,26 @@ export async function install(options: InstallOptions): Promise { await advanceInstallState(context, ownership, "runtime_env_written"); phase = "runtime_env_written"; step("bundled UI"); + ownership.uiTouched = true; await deployUi(context); stepDone("bundled UI"); await advanceInstallState(context, ownership, "ui_deployed"); phase = "ui_deployed"; step("Hysteria2 upstream install"); + ownership.hysteriaTouched = true; await installHysteria(context); stepDone("Hysteria2 upstream install"); await advanceInstallState(context, ownership, "hysteria_installed"); phase = "hysteria_installed"; step("config generation"); + ownership.configTouched = true; await generateConfig(context); stepDone("config generation"); await advanceInstallState(context, ownership, "config_generated"); phase = "config_generated"; step("systemd units"); + ownership.unitsTouched = true; await deploySystemd(context); - ownership.unitsDeployed = true; stepDone("systemd units"); await advanceInstallState(context, ownership, "units_deployed"); phase = "units_deployed"; @@ -267,12 +299,13 @@ export async function install(options: InstallOptions): Promise { await advanceInstallState(context, ownership, "firewall_applied"); phase = "firewall_applied"; step("post-install env"); + ownership.postInstallTouched = true; await writePostInstallEnv(context); - ownership.postInstallWritten = true; stepDone("post-install env"); await advanceInstallState(context, ownership, "postinstall_env_written"); phase = "postinstall_env_written"; step("bootstrap admin secret"); + ownership.bootstrapSecretTouched = true; await ensureBootstrapAdminSecret(context); stepDone("bootstrap admin secret"); await advanceInstallState(context, ownership, "bootstrap_secret_written"); diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index bbe4280..357880a 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -15,7 +15,7 @@ import { deploySystemd } from "../steps/systemd"; import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; import { readInstalledHysteriaVersion, readPackageValue } from "../lib/packageMeta"; import { diagnosticsCollect } from "./diagnostics"; @@ -70,34 +70,34 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l } 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`; - await runVisible`cp -a /etc/systemd/system/hy2xs-admin.service /etc/hy2xs/backups/hy2xs-admin.service.bak 2>/dev/null || true`; - await runVisible`cp -a /etc/systemd/system/hysteria-server.service /etc/hy2xs/backups/hysteria-server.service.bak 2>/dev/null || true`; - await runVisible`cp -a /etc/hy2xs/hy2xs.env /etc/hy2xs/backups/hy2xs.env.bak 2>/dev/null || true`; - await runVisible`cp -a /etc/hysteria/post-install.env /etc/hy2xs/backups/post-install.env.bak 2>/dev/null || true`; - await runVisible`cp -a /etc/nftables.conf /etc/hy2xs/backups/nftables.conf.bak 2>/dev/null || true`; - await runVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`; + await runMutatingVisible`mkdir -p /etc/hy2xs/backups`; + await runMutatingVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/systemd/system/hy2xs-admin.service /etc/hy2xs/backups/hy2xs-admin.service.bak 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/systemd/system/hysteria-server.service /etc/hy2xs/backups/hysteria-server.service.bak 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/hy2xs/hy2xs.env /etc/hy2xs/backups/hy2xs.env.bak 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/hysteria/post-install.env /etc/hy2xs/backups/post-install.env.bak 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/nftables.conf /etc/hy2xs/backups/nftables.conf.bak 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`; - await runVisible`test -f /etc/hy2xs/hy2xs.env && echo 1 > /etc/hy2xs/backups/hy2xs.env.existed || rm -f /etc/hy2xs/backups/hy2xs.env.existed`; - await runVisible`test -f /etc/hysteria/post-install.env && echo 1 > /etc/hy2xs/backups/post-install.env.existed || rm -f /etc/hy2xs/backups/post-install.env.existed`; - await runVisible`test -f /etc/nftables.conf && echo 1 > /etc/hy2xs/backups/nftables.conf.existed || rm -f /etc/hy2xs/backups/nftables.conf.existed`; - await runVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > /etc/hy2xs/backups/hy2xs.nft.existed || rm -f /etc/hy2xs/backups/hy2xs.nft.existed`; + await runMutatingVisible`test -f /etc/hy2xs/hy2xs.env && echo 1 > /etc/hy2xs/backups/hy2xs.env.existed || rm -f /etc/hy2xs/backups/hy2xs.env.existed`; + await runMutatingVisible`test -f /etc/hysteria/post-install.env && echo 1 > /etc/hy2xs/backups/post-install.env.existed || rm -f /etc/hy2xs/backups/post-install.env.existed`; + await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > /etc/hy2xs/backups/nftables.conf.existed || rm -f /etc/hy2xs/backups/nftables.conf.existed`; + await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > /etc/hy2xs/backups/hy2xs.nft.existed || rm -f /etc/hy2xs/backups/hy2xs.nft.existed`; } async function rollbackCurrentState(): Promise { - await runVisible`cp -a /etc/hy2xs/backups/config.yaml.bak /etc/hysteria/config.yaml 2>/dev/null || true`; - await runVisible`cp -a /etc/hy2xs/backups/hy2xs-admin.service.bak /etc/systemd/system/hy2xs-admin.service 2>/dev/null || true`; - await runVisible`cp -a /etc/hy2xs/backups/hysteria-server.service.bak /etc/systemd/system/hysteria-server.service 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/hy2xs/backups/config.yaml.bak /etc/hysteria/config.yaml 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/hy2xs/backups/hy2xs-admin.service.bak /etc/systemd/system/hy2xs-admin.service 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/hy2xs/backups/hysteria-server.service.bak /etc/systemd/system/hysteria-server.service 2>/dev/null || true`; - await runVisible`if [ -f /etc/hy2xs/backups/hy2xs.env.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.env.bak /etc/hy2xs/hy2xs.env 2>/dev/null || true; else rm -f /etc/hy2xs/hy2xs.env; fi`; - await runVisible`if [ -f /etc/hy2xs/backups/post-install.env.existed ]; then cp -a /etc/hy2xs/backups/post-install.env.bak /etc/hysteria/post-install.env 2>/dev/null || true; else rm -f /etc/hysteria/post-install.env; fi`; - await runVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`; - await runVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; + await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.env.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.env.bak /etc/hy2xs/hy2xs.env 2>/dev/null || true; else rm -f /etc/hy2xs/hy2xs.env; fi`; + await runMutatingVisible`if [ -f /etc/hy2xs/backups/post-install.env.existed ]; then cp -a /etc/hy2xs/backups/post-install.env.bak /etc/hysteria/post-install.env 2>/dev/null || true; else rm -f /etc/hysteria/post-install.env; fi`; + await runMutatingVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`; + await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/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`systemctl daemon-reload`; - await runVisible`systemctl restart hysteria-server hy2xs-admin || true`; + await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`; + await runMutatingVisible`systemctl daemon-reload`; + await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`; } async function readInstallState(): Promise { diff --git a/orchestrator/src/commands/status.ts b/orchestrator/src/commands/status.ts index 159f50b..179a2a9 100644 --- a/orchestrator/src/commands/status.ts +++ b/orchestrator/src/commands/status.ts @@ -1,14 +1,14 @@ import type { CommonOptions } from "../types/context"; import { fileExists, readText } from "../lib/fs"; import { info, setOperationContext } from "../lib/log"; -import { run } from "../lib/process"; +import { runReadOnly } from "../lib/process"; import { getPlatformProfile } from "../platform/profile"; import { detectFirewallEntrypointKind } from "../steps/firewall"; import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState"; async function unitState(unit: string): Promise { try { - const out = await run`systemctl is-active ${unit}`; + const out = await runReadOnly`systemctl is-active ${unit}`; return out.trim() || "unknown"; } catch { return "inactive"; @@ -17,7 +17,7 @@ async function unitState(unit: string): Promise { async function firewallState(): Promise { try { - await run`nft -c -f /etc/nftables.conf`; + await runReadOnly`nft -c -f /etc/nftables.conf`; return "valid"; } catch { return "invalid"; @@ -50,7 +50,7 @@ export async function status(_options: CommonOptions): Promise { } } - const rollbackGuardUnits = (await run`sh -c 'systemctl list-units --all --no-legend "hy2xs-fw-rollback-*.timer" "hy2xs-fw-rollback-*.service" 2>/dev/null || true'`).trim(); + const rollbackGuardUnits = (await runReadOnly`sh -c 'systemctl list-units --all --no-legend "hy2xs-fw-rollback-*.timer" "hy2xs-fw-rollback-*.service" 2>/dev/null || true'`).trim(); const hysteriaService = await unitState("hysteria-server"); const adminService = await unitState("hy2xs-admin"); diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index 207576e..a30c26a 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import type { FirewallMode, RuntimeConfig, TlsMode } from "../types/context"; +import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context"; import { GECKO_DEFAULT_MAX_PACKET_SIZE, GECKO_DEFAULT_MIN_PACKET_SIZE, @@ -142,6 +142,19 @@ function normalizeDnsAaaaPolicy(value: string | undefined): "strict" | "warn" | throw new Error(`invalid HY2XS_DNS_AAAA_POLICY: ${value}`); } +/** + * По умолчанию strict: молча принять DNS, ведущий на чужую машину, HY2XS не + * имеет права. Ослабление — осознанное решение оператора для топологий вне + * baseline (NAT, floating IP), а не поведение по умолчанию. + */ +function normalizePublicEndpointPolicy(value: string | undefined): PublicEndpointPolicy { + const policy = value || "strict"; + if (policy === "strict" || policy === "warn" || policy === "off") { + return policy; + } + throw new Error(`invalid HY2XS_PUBLIC_ENDPOINT_POLICY: ${value}`); +} + function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" { const mode = value || "http"; if (mode !== "http") { @@ -227,6 +240,7 @@ export function parseRuntimeEnv(content: string): RuntimeConfig { configSchemaVersion: normalizeConfigSchemaVersion(env.HY2XS_CONFIG_SCHEMA_VERSION), domain: env.HY2XS_DOMAIN || "", dnsAaaaPolicy, + publicEndpointPolicy: normalizePublicEndpointPolicy(env.HY2XS_PUBLIC_ENDPOINT_POLICY), publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""), publicPort: parsePort("HY2XS_PUBLIC_PORT", env.HY2XS_PUBLIC_PORT, hysteriaPort), ipv6Enabled: parseBool("HY2XS_IPV6_ENABLED", env.HY2XS_IPV6_ENABLED, false), @@ -339,6 +353,7 @@ export function renderRuntimeEnv(config: RuntimeConfig): string { `HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`, `HY2XS_DOMAIN=${config.domain}`, `HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`, + `HY2XS_PUBLIC_ENDPOINT_POLICY=${config.publicEndpointPolicy}`, `HY2XS_PUBLIC_HOST=${config.publicHost}`, `HY2XS_PUBLIC_PORT=${config.publicPort}`, `HY2XS_SSH_PORT=${config.sshPort}`, diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts index d259896..ec78b54 100644 --- a/orchestrator/src/config/profile.ts +++ b/orchestrator/src/config/profile.ts @@ -25,6 +25,27 @@ export const HY2XS_RELEASE_LINE = 1; export const HY2XS_TARGET_DEBIAN_VERSION = 13; export const HY2XS_TARGET_ARCH = "amd64"; +/** + * Пространства имён HTTP API админки. + * + * `HYSTERIA_MACHINE_AUTH_PATH` — не внутреннее имя переменной, а runtime-контракт + * продукта: оркестратор записывает этот путь в /etc/hysteria/config.yaml и в + * post-install.env, и по нему Hysteria спрашивает у админки разрешение на + * подключение пира. Раньше эта строка (в терминах H UI) была размазана по + * шаблонам, smoke, assertions, тестам, acceptance и e2e. + * + * Значения обязаны совпадать с константами админки + * (apps/model/constant/api.go). Сверка выполняется на сборке шагом + * verify_versions_contract через tools/print-contract.ts. + */ +export const ADMIN_API_BASE = "/api"; +export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth"; + +/** Полный machine-auth URL, который видит Hysteria. */ +export function hysteriaMachineAuthUrl(uiPort: number, machineToken: string): string { + return `http://127.0.0.1:${uiPort}${HYSTERIA_MACHINE_AUTH_PATH}?access_token=${machineToken}`; +} + export const HYSTERIA_OBFS_TYPES: readonly HysteriaObfsType[] = ["gecko", "salamander"]; /** Тип обфускации для новой установки. Salamander остаётся compatibility fallback. */ diff --git a/orchestrator/src/lib/packageMeta.ts b/orchestrator/src/lib/packageMeta.ts index 340a0c8..4f155b9 100644 --- a/orchestrator/src/lib/packageMeta.ts +++ b/orchestrator/src/lib/packageMeta.ts @@ -1,5 +1,5 @@ import { readText } from "./fs"; -import { run } from "./process"; +import { runReadOnly } from "./process"; export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise { try { @@ -11,7 +11,7 @@ export async function readPackageValue(packageDir: string, file: string, fallbac export async function readInstalledHysteriaVersion(): Promise { try { - const raw = await run`/usr/local/bin/hysteria version`; + const raw = await runReadOnly`/usr/local/bin/hysteria version`; const match = raw.match(/v\d+\.\d+\.\d+/); return match ? match[0] : raw.trim(); } catch { diff --git a/orchestrator/src/lib/process.ts b/orchestrator/src/lib/process.ts index b7297f7..73683ef 100644 --- a/orchestrator/src/lib/process.ts +++ b/orchestrator/src/lib/process.ts @@ -1,3 +1,26 @@ +/** + * Запуск подпроцессов. + * + * API намеренно разделён по namespace'у: read-only и мутирующий. Это не + * стилистика, а часть контракта PHASE 0. + * + * Guard из lib/guard умеет останавливать только то, что через него проходит. + * Пока существовал один универсальный раннер, под которым одинаково жили + * "ss -ltn" и "useradd", инвариант «PHASE 0 ничего не пишет» держался + * исключительно на внимательности автора правки: любой новый вызов с + * мутирующей командой обходил guard молча. + * + * Поэтому здесь нет универсального раннера. Есть два набора: + * + * runReadOnly / runReadOnlySecret + * наблюдение за системой. Guard не трогает — они разрешены в любой фазе. + * + * runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw + * всё, что может изменить хост. Каждый спрашивает разрешения у guard'а. + * + * Выбор набора — сознательное решение на месте вызова, а не умолчание. + */ + import { assertMutationAllowed } from "./guard"; import { info } from "./log"; @@ -20,8 +43,7 @@ function renderCommand(strings: TemplateStringsArray, values: unknown[]): string return command; } -export async function run(command: TemplateStringsArray, ...args: unknown[]): Promise { - const rendered = renderCommand(command, args); +async function capture(rendered: string, includeCommandInError: boolean): Promise { const process = Bun.spawn(["sh", "-c", rendered], { stdout: "pipe", stderr: "pipe" @@ -32,14 +54,42 @@ export async function run(command: TemplateStringsArray, ...args: unknown[]): Pr process.exited ]); if (exitCode !== 0) { - throw new Error(`command failed (${exitCode}): ${rendered}\n${stderr.trim()}`); + throw new Error( + includeCommandInError + ? `command failed (${exitCode}): ${rendered}\n${stderr.trim()}` + : `command failed (${exitCode}): ${stderr.trim()}` + ); } return stdout.trim(); } -export async function runVisible(command: TemplateStringsArray, ...args: unknown[]): Promise { +/** + * Наблюдение за системой: вывод возвращается вызывающему, хост не меняется. + * Команда попадает в текст ошибки — она не содержит секретов по построению. + */ +export async function runReadOnly(command: TemplateStringsArray, ...args: unknown[]): Promise { + return capture(renderCommand(command, args), true); +} + +/** + * То же, но команда НЕ попадает в текст ошибки: аргументы несут секреты + * (machine token, пароль пира), а сообщение уходит в логи и диагностику. + */ +export async function runReadOnlySecret(command: TemplateStringsArray, ...args: unknown[]): Promise { + return capture(renderCommand(command, args), false); +} + +/** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */ +export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); - assertMutationAllowed(`runVisible(${rendered})`); + assertMutationAllowed(`runMutating(${rendered})`); + return capture(rendered, true); +} + +/** Мутация с эхом команды в лог и прямым выводом подпроцесса. */ +export async function runMutatingVisible(command: TemplateStringsArray, ...args: unknown[]): Promise { + const rendered = renderCommand(command, args); + assertMutationAllowed(`runMutatingVisible(${rendered})`); info(`running: ${rendered}`); const process = Bun.spawn(["sh", "-c", rendered], { stdout: "inherit", @@ -51,8 +101,9 @@ export async function runVisible(command: TemplateStringsArray, ...args: unknown } } -export async function runRawVisible(command: string): Promise { - assertMutationAllowed("runRawVisible(...)"); +/** Мутация многострочным скриптом (`sh -eu -c`). */ +export async function runMutatingRaw(command: string): Promise { + assertMutationAllowed("runMutatingRaw(...)"); info(`running script:\n${command}`); const process = Bun.spawn(["sh", "-eu", "-c", command], { stdout: "inherit", @@ -64,9 +115,10 @@ export async function runRawVisible(command: string): Promise { } } -export async function runHidden(command: TemplateStringsArray, ...args: unknown[]): Promise { +/** Мутация без эха команды: аргументы могут содержать секреты. */ +export async function runMutatingHidden(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); - assertMutationAllowed(`runHidden(${rendered})`); + assertMutationAllowed(`runMutatingHidden(${rendered})`); const process = Bun.spawn(["sh", "-c", rendered], { stdout: "inherit", stderr: "inherit" @@ -76,20 +128,3 @@ export async function runHidden(command: TemplateStringsArray, ...args: unknown[ throw new Error(`command failed (${exitCode})`); } } - -export async function runSecret(command: TemplateStringsArray, ...args: unknown[]): Promise { - const rendered = renderCommand(command, args); - const process = Bun.spawn(["sh", "-c", rendered], { - stdout: "pipe", - stderr: "pipe" - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(process.stdout).text(), - new Response(process.stderr).text(), - process.exited - ]); - if (exitCode !== 0) { - throw new Error(`command failed (${exitCode}): ${stderr.trim()}`); - } - return stdout.trim(); -} diff --git a/orchestrator/src/lib/redaction.ts b/orchestrator/src/lib/redaction.ts index 8bbd8d8..aace3a0 100644 --- a/orchestrator/src/lib/redaction.ts +++ b/orchestrator/src/lib/redaction.ts @@ -5,7 +5,7 @@ * Почему структурно, а не regex по строкам: `auth:` в серверном конфиге — это * заголовок mapping'а. Правило вида `auth:\s*(.*)` подставляло маркер в пустое * место и оставляло нетронутым вложенный - * `http.url: …/hui/hysteria2/auth?access_token=<секрет>`, то есть бандл уносил + * `http.url: …?access_token=<секрет>`, то есть бандл уносил * machine token наружу. Значение может быть где угодно в дереве, поэтому * обходить нужно дерево. */ @@ -51,11 +51,6 @@ function isSecretMapPath(path: readonly string[]): boolean { return SECRET_MAP_PATHS.includes(path.join(".")); } -function looksLikeUrlKey(key: string): boolean { - const lowered = key.toLowerCase(); - return lowered === "url" || lowered === "addr" || lowered.endsWith("_url") || lowered.endsWith("url"); -} - /** * Убирает из URL встроенные учётные данные и секретные query-параметры, * сохраняя остальную часть адреса читаемой: в диагностике важно видеть @@ -126,10 +121,8 @@ function redactNode(value: unknown, path: readonly string[]): unknown { out[key] = redactValueDeep(child); continue; } - if (typeof child === "string" && looksLikeUrlKey(key)) { - out[key] = sanitizeUrlValue(child); - continue; - } + // Фильтра по имени ключа здесь нет намеренно: URL-значение проверяется + // по самому значению, а не по тому, назвали ли поле `url`. out[key] = redactNode(child, [...path, key]); } return out; diff --git a/orchestrator/src/platform/profile.ts b/orchestrator/src/platform/profile.ts index 80720f2..7eb0988 100644 --- a/orchestrator/src/platform/profile.ts +++ b/orchestrator/src/platform/profile.ts @@ -1,5 +1,5 @@ import { dirExists, readText } from "../lib/fs"; -import { run } from "../lib/process"; +import { runReadOnly } from "../lib/process"; export type PlatformProfile = { distro: string; @@ -40,7 +40,7 @@ function parseOsRelease(content: string): Record { async function commandExists(command: string): Promise { try { - await run`command -v ${command} >/dev/null 2>&1`; + await runReadOnly`command -v ${command} >/dev/null 2>&1`; return true; } catch { return false; @@ -49,7 +49,7 @@ async function commandExists(command: string): Promise { async function detectOpenSsl3(): Promise { try { - const output = await run`openssl version`; + const output = await runReadOnly`openssl version`; return /^OpenSSL\s+3\./.test(output); } catch { return false; @@ -63,7 +63,7 @@ async function detectSystemd(): Promise<{ ok: boolean; reason: string; state: st let pid1 = "unknown"; try { - pid1 = (await run`ps -p 1 -o comm=`).trim(); + pid1 = (await runReadOnly`ps -p 1 -o comm=`).trim(); } catch { return { ok: false, reason: "unable to inspect PID 1", state: "unknown", pid1: "unknown" }; } @@ -78,7 +78,7 @@ async function detectSystemd(): Promise<{ ok: boolean; reason: string; state: st let state = "unknown"; try { - state = (await run`systemctl is-system-running || true`).trim(); + state = (await runReadOnly`systemctl is-system-running || true`).trim(); } catch { state = "unknown"; } @@ -96,7 +96,7 @@ export async function getPlatformProfile(): Promise { const distro = (parsed.ID || "").toLowerCase(); const majorVersion = Number.parseInt((parsed.VERSION_ID || "").replace(/"/g, ""), 10); - const archRaw = await run`uname -m`; + const archRaw = await runReadOnly`uname -m`; const architecture: PlatformProfile["architecture"] = archRaw.trim() === "x86_64" ? "amd64" : "unsupported"; const nftables = await commandExists("nft"); diff --git a/orchestrator/src/steps/cleanHost.ts b/orchestrator/src/steps/cleanHost.ts index f2d84d5..5af5b77 100644 --- a/orchestrator/src/steps/cleanHost.ts +++ b/orchestrator/src/steps/cleanHost.ts @@ -1,6 +1,6 @@ import type { RuntimeConfig } from "../types/context"; import { dirExists, fileExists } from "../lib/fs"; -import { run } from "../lib/process"; +import { runReadOnly } from "../lib/process"; /** * Контракт чистого хоста для HY2XS v1. @@ -73,6 +73,18 @@ export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = description: "runtime-пакет предыдущей установки HY2XS", createdByInstaller: true }, + { + kind: "dir", + target: "/usr/local/lib/hy2xs", + description: "каталог оркестратора предыдущей установки HY2XS", + createdByInstaller: true + }, + { + kind: "file", + target: "/usr/local/bin/hy2xs-orchestrator", + description: "symlink оркестратора предыдущей установки HY2XS", + createdByInstaller: true + }, { kind: "file", target: "/etc/hysteria/config.yaml", @@ -83,6 +95,14 @@ export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = target: "/usr/local/bin/hysteria", description: "уже установленный бинарник Hysteria" }, + { + // Hysteria хранит здесь ACME-состояние и сертификаты. Установка поверх + // чужого /var/lib/hysteria — это свежий конфиг поверх старого runtime + // state, то есть ровно то, что clean-install-only политика запрещает. + kind: "dir", + target: "/var/lib/hysteria", + description: "runtime/ACME-состояние Hysteria предыдущей установки" + }, { kind: "file", target: "/etc/nftables.d/hy2xs.nft", @@ -121,6 +141,11 @@ export function legacyMarkersFor(config: RuntimeConfig, phase: CleanHostPhase = target: config.dataDir, description: "каталог данных админки HY2XS (включая базу)" }); + markers.push({ + kind: "dir", + target: config.logDir, + description: "каталог логов HY2XS предыдущей установки" + }); const seen = new Set(); return markers.filter((marker) => { @@ -144,7 +169,7 @@ export const defaultHostProbe: HostProbe = { return true; } try { - const listed = await run`systemctl list-unit-files --no-legend ${unit} 2>/dev/null || true`; + const listed = await runReadOnly`systemctl list-unit-files --no-legend ${unit} 2>/dev/null || true`; return listed.trim().length > 0; } catch { return false; diff --git a/orchestrator/src/steps/config.ts b/orchestrator/src/steps/config.ts index fdec678..ad96a67 100644 --- a/orchestrator/src/steps/config.ts +++ b/orchestrator/src/steps/config.ts @@ -1,6 +1,6 @@ import type { RuntimeContext } from "../types/context"; import { readText, renderTemplate, writeText } from "../lib/fs"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; import { DISABLE_LOSS_COMPENSATION, renderCongestionBlock, @@ -51,20 +51,20 @@ export async function generateConfig(context: RuntimeContext): Promise { const configPath = context.config.hysteriaConfigPath; const tmpPath = `${configPath}.tmp`; - await runVisible`mkdir -p /etc/hysteria`; + await runMutatingVisible`mkdir -p /etc/hysteria`; await writeText(tmpPath, rendered, 0o600); - await runVisible`chown hysteria:hy2xs-admin ${tmpPath}`; - await runVisible`chmod 0640 ${tmpPath}`; - await runVisible`mv ${tmpPath} ${configPath}`; + await runMutatingVisible`chown hysteria:hy2xs-admin ${tmpPath}`; + await runMutatingVisible`chmod 0640 ${tmpPath}`; + await runMutatingVisible`mv ${tmpPath} ${configPath}`; 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 runMutatingVisible`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 runMutatingVisible`chmod 600 ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`; } - await runVisible`chown hysteria:hy2xs-admin ${configPath}`; - await runVisible`chmod 0640 ${configPath}`; + await runMutatingVisible`chown hysteria:hy2xs-admin ${configPath}`; + await runMutatingVisible`chmod 0640 ${configPath}`; if (context.config.tlsMode !== "acme") { - await runVisible`chown hysteria:hysteria ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`; + await runMutatingVisible`chown hysteria:hysteria ${context.config.tlsKeyPath} ${context.config.tlsCertPath}`; } } diff --git a/orchestrator/src/steps/configAssertions.ts b/orchestrator/src/steps/configAssertions.ts index f43416d..ee0a0af 100644 --- a/orchestrator/src/steps/configAssertions.ts +++ b/orchestrator/src/steps/configAssertions.ts @@ -4,8 +4,10 @@ import { CONGESTION_TYPE, DISABLE_LOSS_COMPENSATION, DISABLE_STATELESS_RESET, + HYSTERIA_MACHINE_AUTH_PATH, HYSTERIA_OBFS_TYPES, - QUIC_BASELINE + QUIC_BASELINE, + hysteriaMachineAuthUrl } from "../config/profile"; /** @@ -119,14 +121,13 @@ function assertAuthSection(root: YamlRecord, config: RuntimeConfig): void { throw new Error("hysteria config: auth.http.url must carry the HY2XS machine access token"); } - const expectedUrl = - `http://127.0.0.1:${config.uiPort}/hui/hysteria2/auth?access_token=${config.hysteriaTrafficStatsSecret}`; + const expectedUrl = hysteriaMachineAuthUrl(config.uiPort, config.hysteriaTrafficStatsSecret); if (authHttp.url !== expectedUrl) { // Секрет в сообщение не попадает: сравнение уже провалилось, а текст // ошибки уходит в логи и диагностику. throw new Error( `hysteria config: auth.http.url must be http://127.0.0.1:${config.uiPort}` + - "/hui/hysteria2/auth?access_token=" + `${HYSTERIA_MACHINE_AUTH_PATH}?access_token=` ); } diff --git a/orchestrator/src/steps/deps.ts b/orchestrator/src/steps/deps.ts index 4465058..efffedb 100644 --- a/orchestrator/src/steps/deps.ts +++ b/orchestrator/src/steps/deps.ts @@ -1,7 +1,7 @@ import type { InstallContext } from "../types/context"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; export async function installDeps(_context: InstallContext): Promise { - await runVisible`apt-get update`; - await runVisible`apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd`; + await runMutatingVisible`apt-get update`; + await runMutatingVisible`apt-get install -y sudo ca-certificates curl iproute2 tar openssl nftables systemd`; } diff --git a/orchestrator/src/steps/env.ts b/orchestrator/src/steps/env.ts index 69cc8b1..7b2b8bb 100644 --- a/orchestrator/src/steps/env.ts +++ b/orchestrator/src/steps/env.ts @@ -1,6 +1,6 @@ import type { RuntimeContext } from "../types/context"; import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; import { BBR_PROFILE, CONGESTION_TYPE, @@ -79,9 +79,9 @@ export async function ensureBootstrapAdminSecret(context: RuntimeContext): Promi return; } - await runVisible`test "$(stat -c '%U:%G' ${path})" = 'root:root'`; - await runVisible`test "$(stat -c '%a' ${path})" = '600'`; - await runVisible`grep -q '^ADMIN_USER=' ${path}`; - await runVisible`grep -q '^ADMIN_INITIAL_PASSWORD=' ${path}`; - await runVisible`grep -q '^ADMIN_CON_PASS=' ${path}`; + await runMutatingVisible`test "$(stat -c '%U:%G' ${path})" = 'root:root'`; + await runMutatingVisible`test "$(stat -c '%a' ${path})" = '600'`; + await runMutatingVisible`grep -q '^ADMIN_USER=' ${path}`; + await runMutatingVisible`grep -q '^ADMIN_INITIAL_PASSWORD=' ${path}`; + await runMutatingVisible`grep -q '^ADMIN_CON_PASS=' ${path}`; } diff --git a/orchestrator/src/steps/filesystem.ts b/orchestrator/src/steps/filesystem.ts index ce4e201..67e2bc6 100644 --- a/orchestrator/src/steps/filesystem.ts +++ b/orchestrator/src/steps/filesystem.ts @@ -1,9 +1,9 @@ import type { InstallContext } from "../types/context"; -import { run } from "../lib/process"; +import { runMutating } from "../lib/process"; async function userExists(user: string): Promise { try { - await run`id -u ${user} >/dev/null 2>&1`; + await runMutating`id -u ${user} >/dev/null 2>&1`; return true; } catch { return false; @@ -11,19 +11,19 @@ async function userExists(user: string): Promise { } async function getUserField(user: string, field: number): Promise { - return (await run`getent passwd ${user} | cut -d: -f${field}`).trim(); + return (await runMutating`getent passwd ${user} | cut -d: -f${field}`).trim(); } async function getPrimaryGroup(user: string): Promise { - return (await run`id -gn ${user}`).trim(); + return (await runMutating`id -gn ${user}`).trim(); } async function ensureDir(path: string, mode: string, ownerGroup: string): Promise { - await run`install -d -m ${mode} -o ${ownerGroup.split(":")[0]} -g ${ownerGroup.split(":")[1]} ${path}`; + await runMutating`install -d -m ${mode} -o ${ownerGroup.split(":")[0]} -g ${ownerGroup.split(":")[1]} ${path}`; } async function makeUser(user: string, expectedHome: string): Promise { - await run`useradd --system --home ${expectedHome} --shell /usr/sbin/nologin ${user}`; + await runMutating`useradd --system --home ${expectedHome} --shell /usr/sbin/nologin ${user}`; } async function ensureRuntimeIdentity(user: string, expectedHome: string): Promise { @@ -58,5 +58,5 @@ export async function prepareFilesystem(context: InstallContext): Promise await ensureDir(context.config.installDir, "0755", "root:root"); await ensureDir("/usr/local/lib/hy2xs", "0755", "root:root"); await ensureDir("/etc/nftables.d", "0755", "root:root"); - await run`chmod -R go-w ${context.config.installDir}`; + await runMutating`chmod -R go-w ${context.config.installDir}`; } diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts index 9e0ca1f..cd2635d 100644 --- a/orchestrator/src/steps/firewall.ts +++ b/orchestrator/src/steps/firewall.ts @@ -1,7 +1,7 @@ import type { RuntimeContext } from "../types/context"; import { fileExists, readText, renderTemplate, writeText } from "../lib/fs"; import { fail, info } from "../lib/log"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; type NftEntrypointKind = | "missing" @@ -34,11 +34,11 @@ function rollbackBackup(path: string, opId: string): string { } async function ensureRollbackRoot(opId: string): Promise { - await runVisible`mkdir -p ${rollbackRoot(opId)}`; + await runMutatingVisible`mkdir -p ${rollbackRoot(opId)}`; } async function cleanupFirewallBackupFiles(opId: string): Promise { - await runVisible`rm -rf ${rollbackRoot(opId)}`; + await runMutatingVisible`rm -rf ${rollbackRoot(opId)}`; } function stripNftComments(content: string): string { @@ -126,13 +126,13 @@ export async function applyFirewall(context: RuntimeContext): Promise { } await ensureRollbackRoot(opId); - await runVisible`touch ${rollbackMarker(opId)}`; - await runVisible`cp -a /etc/nftables.conf ${rollbackBackup("nftables.conf.bak", opId)} 2>/dev/null || true`; - await runVisible`cp -a /etc/nftables.d/hy2xs.nft ${rollbackBackup("hy2xs.nft.bak", opId)} 2>/dev/null || true`; - await runVisible`test -f /etc/nftables.conf && echo 1 > ${rollbackBackup("nftables.conf.existed", opId)} || rm -f ${rollbackBackup("nftables.conf.existed", opId)}`; - await runVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > ${rollbackBackup("hy2xs.nft.existed", opId)} || rm -f ${rollbackBackup("hy2xs.nft.existed", opId)}`; + await runMutatingVisible`touch ${rollbackMarker(opId)}`; + await runMutatingVisible`cp -a /etc/nftables.conf ${rollbackBackup("nftables.conf.bak", opId)} 2>/dev/null || true`; + await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft ${rollbackBackup("hy2xs.nft.bak", opId)} 2>/dev/null || true`; + await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > ${rollbackBackup("nftables.conf.existed", opId)} || rm -f ${rollbackBackup("nftables.conf.existed", opId)}`; + await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > ${rollbackBackup("hy2xs.nft.existed", opId)} || rm -f ${rollbackBackup("hy2xs.nft.existed", opId)}`; await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600); - await runVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`; + await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`; const nftablesConfCandidate = `#!/usr/sbin/nft -f # HY2XS-MANAGED: root nftables entrypoint @@ -143,9 +143,9 @@ flush ruleset include "/etc/nftables.d/hy2xs.nft.candidate" `; await writeText("/etc/nftables.conf.candidate", nftablesConfCandidate, 0o644); - await runVisible`nft -c -f /etc/nftables.conf.candidate`; + await runMutatingVisible`nft -c -f /etc/nftables.conf.candidate`; - await runVisible`mv /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft`; + await runMutatingVisible`mv /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft`; const nftablesConf = `#!/usr/sbin/nft -f # HY2XS-MANAGED: root nftables entrypoint @@ -156,17 +156,17 @@ flush ruleset include "/etc/nftables.d/hy2xs.nft" `; await writeText("/etc/nftables.conf", nftablesConf, 0o644); - await runVisible`nft -c -f /etc/nftables.conf`; + await runMutatingVisible`nft -c -f /etc/nftables.conf`; if (context.config.firewallStagedApply) { const unit = rollbackUnit(opId); - await runVisible`systemd-run --unit ${unit} --on-active=45s /bin/sh -c 'if [ -f ${rollbackMarker(opId)} ]; then if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then cp -a ${rollbackBackup("nftables.conf.bak", opId)} /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi; if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi; if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi; fi'`; + await runMutatingVisible`systemd-run --unit ${unit} --on-active=45s /bin/sh -c 'if [ -f ${rollbackMarker(opId)} ]; then if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then cp -a ${rollbackBackup("nftables.conf.bak", opId)} /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi; if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi; if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi; fi'`; } - await runVisible`nft -f /etc/nftables.conf`; - await runVisible`systemctl enable --now nftables`; + await runMutatingVisible`nft -f /etc/nftables.conf`; + await runMutatingVisible`systemctl enable --now nftables`; - await runVisible`ss -H -ltn | grep -q ':${context.config.sshPort} ' || (echo 'ssh port check failed' >&2; exit 1)`; + await runMutatingVisible`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"); } @@ -179,8 +179,8 @@ export async function cancelFirewallRollback(context: RuntimeContext): Promise/dev/null || true; else rm -f /etc/nftables.conf; fi`; - await runVisible`if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; - await runVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi`; + await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then cp -a ${rollbackBackup("nftables.conf.bak", opId)} /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`; + await runMutatingVisible`if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; + await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi`; await cleanupFirewallBackupFiles(opId); } diff --git a/orchestrator/src/steps/hysteria.ts b/orchestrator/src/steps/hysteria.ts index 0d3702c..6279569 100644 --- a/orchestrator/src/steps/hysteria.ts +++ b/orchestrator/src/steps/hysteria.ts @@ -1,5 +1,5 @@ import type { InstallContext } from "../types/context"; -import { run, runVisible } from "../lib/process"; +import { runMutating, runMutatingVisible, runReadOnly } from "../lib/process"; function normalizeInstalledVersion(raw: string): string { const match = raw.match(/v\d+\.\d+\.\d+/); @@ -19,20 +19,20 @@ function validatePinnedVersion(value: string): void { export async function installHysteria(context: InstallContext): Promise { validatePinnedVersion(context.hysteriaTargetVersion); - const tmpDir = await run`mktemp -d`; + const tmpDir = await runMutating`mktemp -d`; const tmp = `${tmpDir.trim()}/hysteria-linux-amd64`; 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`; + await runMutatingVisible`curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location ${context.hysteriaArtifactUrl} -o ${tmp}`; + await runMutatingVisible`test -s ${tmp}`; + await runMutatingVisible`printf '%s %s\n' ${context.hysteriaArtifactSha256} ${tmp} | sha256sum -c -`; + await runMutatingVisible`install -m 0755 -o root -g root ${tmp} /usr/local/bin/hysteria`; } finally { - await runVisible`rm -rf ${tmpDir.trim()}`; + await runMutatingVisible`rm -rf ${tmpDir.trim()}`; } - await runVisible`test -x /usr/local/bin/hysteria`; - const versionOutput = await run`/usr/local/bin/hysteria version`; + await runMutatingVisible`test -x /usr/local/bin/hysteria`; + const versionOutput = await runReadOnly`/usr/local/bin/hysteria version`; const installedVersion = normalizeInstalledVersion(versionOutput); context.hysteriaVersion = installedVersion; diff --git a/orchestrator/src/steps/networkEndpoint.ts b/orchestrator/src/steps/networkEndpoint.ts new file mode 100644 index 0000000..594deef --- /dev/null +++ b/orchestrator/src/steps/networkEndpoint.ts @@ -0,0 +1,230 @@ +import { networkInterfaces } from "node:os"; +import type { RuntimeConfig } from "../types/context"; + +/** + * Инвариант публичного endpoint: DNS обязан вести на ЭТОТ сервер. + * + * Что было. Preflight проверял ровно одно: «у HY2XS_DOMAIN есть A-запись». + * Запись, указывающая на другую машину, эту проверку проходила. После + * принудительной смены IP провайдером сервер продолжал рапортовать успех, а + * клиентская ссылка отправляла людей на чужой адрес. Никакой другой шаг это + * не ловил: smoke проверяет systemd, listeners, auth и конфиг — то есть + * состояние ЭТОЙ машины, а не то, куда указывает мир. + * + * Второй пробел был шире исходной формулировки: проверялся HY2XS_DOMAIN, а в + * hysteria2:// попадает HY2XS_PUBLIC_HOST. По умолчанию они совпадают, но + * архитектурно это разные сущности (TLS-домен и публичный endpoint), и + * проверялся именно не тот. + * + * Как определяется «этот сервер». Только локально, через список назначенных + * интерфейсам адресов. Внешние сервисы определения IP здесь не используются: + * это добавило бы doctor сетевую зависимость и превратило бы недоступность + * стороннего сервиса в ложный отказ установки. + */ + +export type EndpointProbe = { + resolve4(hostname: string): Promise; + getLocalPublicIpv4s(): string[]; +}; + +export type EndpointProblemKind = "resolution" | "mismatch" | "no_local_address"; + +export type EndpointProblem = { + kind: EndpointProblemKind; + message: string; +}; + +const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/; + +/** + * Диапазоны, которые не могут быть публичным endpoint'ом HY2XS. + * + * Baseline продукта — выделенный сервер с обычным публичным IPv4; NAT и + * Hysteria Realms описаны в документации как отдельная топология вне baseline. + * Поэтому адрес из этих диапазонов не является ответом на вопрос «какой у + * сервера публичный IP». + */ +const NON_PUBLIC_IPV4_RANGES: readonly { cidr: string; description: string }[] = [ + { cidr: "0.0.0.0/8", description: "this network" }, + { cidr: "10.0.0.0/8", description: "private" }, + { cidr: "100.64.0.0/10", description: "CGNAT" }, + { cidr: "127.0.0.0/8", description: "loopback" }, + { cidr: "169.254.0.0/16", description: "link-local" }, + { cidr: "172.16.0.0/12", description: "private" }, + { cidr: "192.168.0.0/16", description: "private" }, + { cidr: "224.0.0.0/4", description: "multicast" }, + { cidr: "240.0.0.0/4", description: "reserved" } +]; + +export function isIpv4Literal(value: string): boolean { + return IPV4_PATTERN.test(value.trim()); +} + +function ipv4ToUint32(ip: string): number { + const octets = ip.trim().split("."); + return ( + ((Number(octets[0]) << 24) >>> 0) + + (Number(octets[1]) << 16) + + (Number(octets[2]) << 8) + + Number(octets[3]) + ) >>> 0; +} + +function inCidr(ip: string, cidr: string): boolean { + const [network, prefixRaw] = cidr.split("/"); + const prefix = Number(prefixRaw); + if (prefix === 0) { + return true; + } + const mask = (0xffffffff << (32 - prefix)) >>> 0; + return (ipv4ToUint32(ip) & mask) >>> 0 === (ipv4ToUint32(network) & mask) >>> 0; +} + +/** Публичный маршрутизируемый IPv4 — то, чем может быть endpoint сервера. */ +export function isRoutablePublicIpv4(value: string): boolean { + if (!isIpv4Literal(value)) { + return false; + } + return !NON_PUBLIC_IPV4_RANGES.some((range) => inCidr(value, range.cidr)); +} + +/** + * Публичные IPv4, фактически назначенные интерфейсам этой машины. + * + * Сервер может иметь несколько публичных адресов — это нормально и не должно + * считаться ошибкой. Достаточно, чтобы DNS указывал на один из них. + */ +export function getLocalPublicIpv4s(): string[] { + const found = new Set(); + for (const addresses of Object.values(networkInterfaces())) { + for (const address of addresses ?? []) { + // Node ≥18 отдаёт family как "IPv4", более старые — как 4. + const isIpv4 = address.family === "IPv4" || (address.family as unknown as number) === 4; + if (!isIpv4 || address.internal) { + continue; + } + if (isRoutablePublicIpv4(address.address)) { + found.add(address.address); + } + } + } + return [...found].sort(); +} + +export const defaultEndpointProbe: EndpointProbe = { + async resolve4(hostname: string): Promise { + const { resolve4 } = await import("node:dns/promises"); + return resolve4(hostname); + }, + getLocalPublicIpv4s +}; + +function renderMismatch(label: string, host: string, records: readonly string[], local: readonly string[]): string { + return [ + `DNS IPv4 mismatch for ${label} ${host}:`, + ` DNS A records: ${records.join(", ")}`, + ` server public IPv4: ${local.join(", ")}`, + "", + "Update the DNS A record before using this server." + ].join("\n"); +} + +/** + * Разрешение имени отделено от сравнения намеренно. + * + * Отсутствие A-записи — фатальная проблема при любой политике, а сравнение с + * локальными адресами политикой управляется. Если бы шаг «резолв» выполнялся + * только при непустом локальном множестве, то на сервере за NAT с + * HY2XS_PUBLIC_ENDPOINT_POLICY=off домен без A-записи прошёл бы молча. + */ +async function resolveHost( + label: string, + host: string, + probe: EndpointProbe +): Promise<{ records: string[]; problems: EndpointProblem[] }> { + if (isIpv4Literal(host)) { + if (!isRoutablePublicIpv4(host)) { + return { + records: [host], + problems: [ + { + kind: "mismatch", + message: `${label} ${host} is not a routable public IPv4 address` + } + ] + }; + } + return { records: [host], problems: [] }; + } + + let records: string[] = []; + try { + records = await probe.resolve4(host); + } catch { + return { records: [], problems: [{ kind: "resolution", message: `${label} has no A-record: ${host}` }] }; + } + if (records.length === 0) { + return { records: [], problems: [{ kind: "resolution", message: `${label} has no A-record: ${host}` }] }; + } + return { records, problems: [] }; +} + +/** + * Собирает все расхождения публичного endpoint. Ничего не бросает: решение, + * что считать фатальным, принимает вызывающий по HY2XS_PUBLIC_ENDPOINT_POLICY. + */ +export async function checkPublicEndpoint( + config: RuntimeConfig, + probe: EndpointProbe = defaultEndpointProbe +): Promise { + const local = probe.getLocalPublicIpv4s(); + const problems: EndpointProblem[] = []; + + // HY2XS_DOMAIN — это TLS/ACME-домен. Он проверяется отдельно от публичного + // endpoint'а, потому что может от него отличаться, и ACME-челлендж придёт + // именно на него. + const hosts: [string, string][] = [["HY2XS_PUBLIC_HOST", config.publicHost]]; + if (config.domain && config.domain !== config.publicHost) { + hosts.push(["HY2XS_DOMAIN", config.domain]); + } + + const resolved: { label: string; host: string; records: string[] }[] = []; + for (const [label, host] of hosts) { + const outcome = await resolveHost(label, host, probe); + problems.push(...outcome.problems); + if (outcome.problems.length === 0) { + resolved.push({ label, host, records: outcome.records }); + } + } + + if (local.length === 0) { + // Сравнивать не с чем. Один внятный диагноз лучше, чем каскад одинаковых + // сообщений об одном и том же по каждому имени. + problems.push({ + kind: "no_local_address", + message: [ + "На сервере не найдено ни одного публичного маршрутизируемого IPv4.", + "HY2XS рассчитан на выделенный сервер с обычным публичным IPv4;", + "NAT и Hysteria Realms — отдельная топология вне baseline.", + "Если это осознанная конфигурация, задайте HY2XS_PUBLIC_ENDPOINT_POLICY=warn." + ].join("\n") + }); + return problems; + } + + // Требуется, чтобы КАЖДАЯ A-запись вела на этот сервер. Лишний адрес рядом + // с правильным — это второй, чужой backend за тем же именем: для + // single-host профиля HY2XS это не балансировка, а ошибка конфигурации DNS, + // при которой часть клиентов попадёт не туда. + for (const entry of resolved) { + const foreign = entry.records.filter((record) => !local.includes(record)); + if (foreign.length > 0) { + problems.push({ + kind: "mismatch", + message: renderMismatch(entry.label, entry.host, entry.records, local) + }); + } + } + + return problems; +} diff --git a/orchestrator/src/steps/preflight.ts b/orchestrator/src/steps/preflight.ts index 0d92878..74a32dc 100644 --- a/orchestrator/src/steps/preflight.ts +++ b/orchestrator/src/steps/preflight.ts @@ -1,11 +1,12 @@ -import type { RuntimeContext } from "../types/context"; -import { resolve4, resolve6 } from "node:dns/promises"; +import type { RuntimeConfig, RuntimeContext } from "../types/context"; +import { resolve6 } from "node:dns/promises"; import { fileExists } from "../lib/fs"; import { fail, info } from "../lib/log"; -import { run } from "../lib/process"; +import { runReadOnly } from "../lib/process"; import { assertPlatform } from "../platform/assert"; import { HY2XS_TARGET_ARCH, HY2XS_TARGET_DEBIAN_VERSION } from "../config/profile"; import { assertCleanHost, type CleanHostPhase } from "./cleanHost"; +import { checkPublicEndpoint, defaultEndpointProbe, type EndpointProbe } from "./networkEndpoint"; type PreflightOptions = { requireCapabilities?: boolean; @@ -14,8 +15,44 @@ type PreflightOptions = { * install — PHASE 1, install.sh уже разложил runtime-пакет. */ cleanHostPhase?: CleanHostPhase; + /** Подменяется в тестах, чтобы не зависеть от сети и интерфейсов машины. */ + endpointProbe?: EndpointProbe; }; +/** + * Применяет HY2XS_PUBLIC_ENDPOINT_POLICY к найденным расхождениям. + * + * Отсутствие A-записи фатально при любой политике: имя без A-записи не + * работает ни в какой топологии, и ослаблять здесь нечего. Политика управляет + * только сравнением с локальными адресами. + */ +export async function assertPublicEndpoint( + config: RuntimeConfig, + probe: EndpointProbe = defaultEndpointProbe +): Promise { + const problems = await checkPublicEndpoint(config, probe); + if (problems.length === 0) { + return; + } + + const unresolvable = problems.filter((problem) => problem.kind === "resolution"); + if (unresolvable.length > 0) { + fail(unresolvable.map((problem) => problem.message).join("\n\n")); + } + + if (config.publicEndpointPolicy === "off") { + return; + } + + const rest = problems.map((problem) => problem.message).join("\n\n"); + if (config.publicEndpointPolicy === "warn") { + info(`warning: ${rest}\n(продолжаем из-за HY2XS_PUBLIC_ENDPOINT_POLICY=warn)`); + return; + } + + fail(rest); +} + function isNoDnsRecords(error: unknown): boolean { return ( typeof error === "object" && @@ -27,7 +64,7 @@ function isNoDnsRecords(error: unknown): boolean { async function isTcpPortListening(port: number): Promise { try { - const output = await run`ss -H -ltn`; + const output = await runReadOnly`ss -H -ltn`; return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`)); } catch { return false; @@ -36,7 +73,7 @@ async function isTcpPortListening(port: number): Promise { async function isUdpPortListening(port: number): Promise { try { - const output = await run`ss -H -lun`; + const output = await runReadOnly`ss -H -lun`; return output.split("\n").some((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`)); } catch { return false; @@ -45,7 +82,7 @@ async function isUdpPortListening(port: number): Promise { async function isUnitActive(unit: string): Promise { try { - await run`systemctl is-active --quiet ${unit}`; + await runReadOnly`systemctl is-active --quiet ${unit}`; return true; } catch { return false; @@ -96,7 +133,7 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti if (!isReconfigure) { try { - await run`command -v sudo >/dev/null 2>&1`; + await runReadOnly`command -v sudo >/dev/null 2>&1`; } catch { info("sudo not found in preflight: installDeps step will install sudo before smoke checks"); } @@ -140,17 +177,12 @@ export async function preflight(context: RuntimeContext, options?: PreflightOpti } } - if (context.config.domain) { - let a: string[] = []; - try { - a = await resolve4(context.config.domain); - } catch { - fail(`domain has no A-record: ${context.config.domain}`); - } - if (a.length === 0) { - fail(`domain has no A-record: ${context.config.domain}`); - } + // Публичный endpoint обязан вести на этот сервер. Проверка живёт здесь, а не + // в doctor: preflight общий для install, reconfigure и doctor, поэтому + // инвариант автоматически действует во всех трёх сценариях. + await assertPublicEndpoint(context.config, options?.endpointProbe); + if (context.config.domain) { let aaaa: string[] = []; try { aaaa = await resolve6(context.config.domain); diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index 4b2ee9b..0fab8a4 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -1,7 +1,8 @@ import type { RuntimeContext } from "../types/context"; import { info } from "../lib/log"; import { readText } from "../lib/fs"; -import { runHidden, runSecret, runVisible } from "../lib/process"; +import { runMutatingHidden, runReadOnlySecret, runMutatingVisible } from "../lib/process"; +import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; function parseLocalAddress(line: string): string { @@ -51,7 +52,7 @@ async function retry( } if (i < attempts - 1) { info(`${label}: retry ${i + 1}/${attempts}`); - await runHidden`sleep ${Math.max(1, Math.ceil(delayMs / 1000))}`; + await runMutatingHidden`sleep ${Math.max(1, Math.ceil(delayMs / 1000))}`; } } throw errorFactory(lastValue, lastError); @@ -64,7 +65,7 @@ export async function smoke(context: RuntimeContext): Promise { } if (!context.options.skipServiceStart) { - await runVisible`systemctl restart hysteria-server hy2xs-admin`; + await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin`; } else { info("service restart skipped by flag"); } @@ -73,7 +74,7 @@ export async function smoke(context: RuntimeContext): Promise { "systemd hysteria-server active", 15, 1000, - async () => runSecret`systemctl is-active hysteria-server || true`, + async () => runReadOnlySecret`systemctl is-active hysteria-server || true`, (state) => state.trim() === "active", (state, error) => new Error(`hysteria-server is not active: ${state ?? String(error)}`), ); @@ -81,7 +82,7 @@ export async function smoke(context: RuntimeContext): Promise { "systemd hy2xs-admin active", 15, 1000, - async () => runSecret`systemctl is-active hy2xs-admin || true`, + async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`, (state) => state.trim() === "active", (state, error) => new Error(`hy2xs-admin is not active: ${state ?? String(error)}`), ); @@ -95,7 +96,7 @@ export async function smoke(context: RuntimeContext): Promise { "ui tcp listener readiness", 15, 1000, - async () => runSecret`ss -H -ltn`, + async () => runReadOnlySecret`ss -H -ltn`, (lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort), (lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`), ); @@ -103,7 +104,7 @@ export async function smoke(context: RuntimeContext): Promise { "hysteria udp listener readiness", 15, 1000, - async () => runSecret`ss -H -lun`, + async () => runReadOnlySecret`ss -H -lun`, (lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort), (lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`), ); @@ -111,42 +112,51 @@ export async function smoke(context: RuntimeContext): Promise { "admin healthz readiness", 15, 1000, - async () => runSecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`, + async () => runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`, (response) => /"ok"\s*:\s*true/.test(response), (response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`), ); - 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`test -s ${context.config.bootstrapAdminSecretPath}`; - await runVisible`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`; - await runVisible`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`; - await runVisible`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`; - await runVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`; - await runVisible`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`; - await runVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; - await runVisible`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`; - await runVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`; - await runVisible`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`; - await runVisible`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`; - await runVisible`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`; - await runVisible`sudo -u hysteria test -r /etc/hysteria/config.yaml`; - await runVisible`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`; - await runVisible`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`; - await runVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`; - await runVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`; - await runVisible`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`; + await runMutatingVisible`/usr/local/bin/hysteria version`; + await runMutatingVisible`test -s /etc/hysteria/config.yaml`; + await runMutatingVisible`test -s /etc/hy2xs/hy2xs.env`; + await runMutatingVisible`test -s /etc/hysteria/post-install.env`; + await runMutatingVisible`test -s ${context.config.bootstrapAdminSecretPath}`; + await runMutatingVisible`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`; + await runMutatingVisible`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`; + await runMutatingVisible`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`; + await runMutatingVisible`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`; + await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`; + await runMutatingVisible`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; + await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`; + await runMutatingVisible`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`; + await runMutatingVisible`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`; + await runMutatingVisible`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`; + await runMutatingVisible`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`; + await runMutatingVisible`sudo -u hysteria test -r /etc/hysteria/config.yaml`; + await runMutatingVisible`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`; + await runMutatingVisible`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`; + await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`; + await runMutatingVisible`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`; + await runMutatingVisible`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`; if (context.config.uiBindHost === "127.0.0.1") { - const tcp = await runSecret`ss -H -ltn`; + const tcp = await runReadOnlySecret`ss -H -ltn`; if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) { throw new Error(`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`); } } - await runVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; - await runVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; - const missingTokenAuthCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth`; + await runMutatingVisible`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; + await runMutatingVisible`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; + + // Путь machine-auth берётся из профиля, а не пишется здесь литералом: это + // тот же контракт, который уезжает в /etc/hysteria/config.yaml. + const machineAuthUrlNoToken = `http://127.0.0.1:${context.config.uiPort}${HYSTERIA_MACHINE_AUTH_PATH}`; + const machineAuthUrl = hysteriaMachineAuthUrl( + context.config.uiPort, + context.config.hysteriaTrafficStatsSecret + ); + + const missingTokenAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`; if (missingTokenAuthCode.trim() !== "403") { throw new Error(`unexpected auth status without machine token: ${missingTokenAuthCode}`); } @@ -154,25 +164,25 @@ export async function smoke(context: RuntimeContext): Promise { "auth invalid credentials", 5, 1000, - async () => runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' 'http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth?access_token=${context.config.hysteriaTrafficStatsSecret}'`, + async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`, (response) => /"ok"\s*:\s*false/.test(response), (response, error) => new Error(`unexpected auth response for invalid credentials: ${response ?? String(error)}`), ); for (let i = 0; i < 10; i += 1) { - const response = await runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' 'http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth?access_token=${context.config.hysteriaTrafficStatsSecret}'`; + const response = await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`; if (!/"ok"\s*:\s*false/.test(response)) { throw new Error(`unexpected auth response during rate-limit smoke: ${response}`); } } - const invalidTypeAuthCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' 'http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth?access_token=${context.config.hysteriaTrafficStatsSecret}'`; + const invalidTypeAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`; if (invalidTypeAuthCode.trim() !== "400") { throw new Error(`unexpected auth status for tx as string: ${invalidTypeAuthCode}`); } if (context.mode === "install") { - const adminConPass = (await runSecret`grep '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`).trim(); + const adminConPass = (await runReadOnlySecret`grep '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`).trim(); if (!adminConPass) { throw new Error("admin connection password is empty in bootstrap secret file"); } @@ -181,7 +191,7 @@ export async function smoke(context: RuntimeContext): Promise { "auth valid credentials", 10, 1000, - async () => runSecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' 'http://127.0.0.1:${context.config.uiPort}/hui/hysteria2/auth?access_token=${context.config.hysteriaTrafficStatsSecret}'`, + async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`, (response) => /"ok"\s*:\s*true/.test(response), (response, error) => new Error(`unexpected auth response for valid credentials: ${response ?? String(error)}`), ); @@ -191,16 +201,16 @@ export async function smoke(context: RuntimeContext): Promise { "trafficStats valid secret", 10, 1000, - async () => runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`, + async () => runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`, (code) => /^2\d\d$/.test(code.trim()), (code, error) => new Error(`unexpected trafficStats status for valid secret: ${code ?? String(error)}`), ); - const deniedCode = await runSecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`; + const deniedCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`; if (!/(401|403)/.test(deniedCode)) { throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`); } - await runVisible`nft -c -f /etc/nftables.conf`; + await runMutatingVisible`nft -c -f /etc/nftables.conf`; // Семантическая проверка установленного конфига: разбираем YAML и сверяем // с production-профилем, а не ищем подстроки. @@ -221,7 +231,7 @@ async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise< return; } - const raw = await runSecret`/usr/local/bin/hysteria version`; + const raw = await runReadOnlySecret`/usr/local/bin/hysteria version`; const match = raw.match(/v\d+\.\d+\.\d+/); const effective = match ? match[0] : raw.trim(); diff --git a/orchestrator/src/steps/systemd.ts b/orchestrator/src/steps/systemd.ts index 33faac9..2b8b9b3 100644 --- a/orchestrator/src/steps/systemd.ts +++ b/orchestrator/src/steps/systemd.ts @@ -1,6 +1,6 @@ import type { RuntimeContext } from "../types/context"; import { readText, renderTemplate, writeText } from "../lib/fs"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; export async function deploySystemd(context: RuntimeContext): Promise { const values = { @@ -16,6 +16,6 @@ export async function deploySystemd(context: RuntimeContext): Promise { await writeText("/etc/systemd/system/hysteria-server.service", hysteriaUnit, 0o644); await writeText("/etc/systemd/system/hy2xs-admin.service", adminUnit, 0o644); - await runVisible`systemctl daemon-reload`; - await runVisible`systemctl enable hysteria-server hy2xs-admin`; + await runMutatingVisible`systemctl daemon-reload`; + await runMutatingVisible`systemctl enable hysteria-server hy2xs-admin`; } diff --git a/orchestrator/src/steps/ui.ts b/orchestrator/src/steps/ui.ts index 3906fb7..52de6b9 100644 --- a/orchestrator/src/steps/ui.ts +++ b/orchestrator/src/steps/ui.ts @@ -1,8 +1,8 @@ import type { InstallContext } from "../types/context"; -import { runVisible } from "../lib/process"; +import { runMutatingVisible } from "../lib/process"; export async function deployUi(context: InstallContext): Promise { - 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}`; + await runMutatingVisible`cp -a ${context.options.packageDir}/ui/hy2xs-admin/. ${context.config.installDir}/`; + await runMutatingVisible`chown -R root:root ${context.config.installDir}`; + await runMutatingVisible`chmod -R go-w ${context.config.installDir}`; } diff --git a/orchestrator/src/types/context.ts b/orchestrator/src/types/context.ts index e6c3d4e..be9957c 100644 --- a/orchestrator/src/types/context.ts +++ b/orchestrator/src/types/context.ts @@ -24,12 +24,25 @@ export type TlsMode = "acme" | "file" | "self_signed_dev"; export type DnsAaaaPolicy = "strict" | "warn" | "off"; +/** + * Насколько строго проверяется, что публичный endpoint ведёт на этот сервер. + * + * strict — расхождение DNS и локальных публичных IPv4 останавливает операцию; + * warn — печатается предупреждение (сервер за NAT, floating IP, anycast); + * off — проверка соответствия не выполняется вовсе. + * + * Существование A-записи проверяется при любом значении: домен без A-записи + * не работает ни в какой топологии. + */ +export type PublicEndpointPolicy = "strict" | "warn" | "off"; + export type HysteriaObfsType = "gecko" | "salamander"; export type RuntimeConfig = { configSchemaVersion: number; domain: string; dnsAaaaPolicy: DnsAaaaPolicy; + publicEndpointPolicy: PublicEndpointPolicy; publicHost: string; publicPort: number; ipv6Enabled: boolean; diff --git a/orchestrator/test/clean-host.test.ts b/orchestrator/test/clean-host.test.ts index 103ffdc..f4fed9b 100644 --- a/orchestrator/test/clean-host.test.ts +++ b/orchestrator/test/clean-host.test.ts @@ -55,20 +55,74 @@ describe("clean-host контракт", () => { "/var/lib/hy2xs/install-state.json", "/etc/hy2xs/bootstrap-admin.secret", "/usr/local/lib/hy2xs/package", + "/usr/local/lib/hy2xs", + "/usr/local/bin/hy2xs-orchestrator", "/etc/hysteria/config.yaml", "/usr/local/bin/hysteria", + "/var/lib/hysteria", "/etc/nftables.d/hy2xs.nft", "hy2xs-admin.service", "hysteria-server.service", "h-ui.service", "/usr/local/h-ui", config.installDir, - config.dataDir + config.dataDir, + config.logDir ]) { expect(targets).toContain(expected); } }); + /** + * purge-v0.sh и clean-host обязаны описывать одну и ту же границу. + * + * Регрессия: purge удалял /var/lib/hysteria, /var/log/hy2xs, + * /usr/local/lib/hy2xs и /usr/local/bin/hy2xs-orchestrator, а clean-host их + * не проверял. Сервер, где остался только старый ACME-state Hysteria, + * проходил проверку и получал свежую установку поверх чужого runtime. + */ + test("всё, что удаляет purge-v0.sh, проверяется clean-host контрактом", () => { + const purgedPaths = [ + "/etc/hy2xs", + "/etc/hysteria", + "/var/lib/hy2xs", + "/var/lib/hy2xs-admin", + "/var/lib/hysteria", + "/var/log/hy2xs", + "/opt/hy2xs-admin", + "/usr/local/lib/hy2xs", + "/usr/local/h-ui", + "/usr/local/bin/hy2xs-orchestrator", + "/usr/local/bin/hysteria", + "/etc/nftables.d/hy2xs.nft" + ]; + const targets = legacyMarkersFor(config, "bootstrap").map((marker) => marker.target); + + for (const purged of purgedPaths) { + const covered = targets.some((target) => target === purged || target.startsWith(`${purged}/`)); + expect(covered, `purge удаляет ${purged}, но clean-host его не проверяет`).toBe(true); + } + }); + + /** + * Пути, которые install.sh законно создаёт между фазами, обязаны быть + * маркером в PHASE 0 и перестать им быть в PHASE 1 — иначе установка + * отказала бы на собственном, только что разложенном оркестраторе. + */ + test("пути, созданные install.sh между фазами, не блокируют PHASE 1", async () => { + for (const target of [ + "/usr/local/lib/hy2xs", + "/usr/local/lib/hy2xs/package", + "/usr/local/bin/hy2xs-orchestrator" + ]) { + const probe = probeWith([target]); + await expect(assertCleanHost(config, "bootstrap", probe)).rejects.toThrow( + /предыдущая или посторонняя установка/ + ); + await expect(assertCleanHost(config, "install", probe)).resolves.toBeUndefined(); + } + }); + test("пути из конфигурации попадают в список, а не только дефолтные", () => { const custom = baselineConfig({ HY2XS_INSTALL_DIR: "/srv/hy2xs-app", diff --git a/orchestrator/test/config-assertions.test.ts b/orchestrator/test/config-assertions.test.ts index 152351f..e660161 100644 --- a/orchestrator/test/config-assertions.test.ts +++ b/orchestrator/test/config-assertions.test.ts @@ -138,13 +138,13 @@ describe("подмены в конфиге обнаруживаются", () => test("auth url указывает на другой порт", () => { const { config, yaml } = renderFor(); - const tampered = yaml.replace("http://127.0.0.1:8080/hui", "http://127.0.0.1:9090/hui"); + const tampered = yaml.replace("http://127.0.0.1:8080/", "http://127.0.0.1:9090/"); expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/); }); test("auth url указывает на другой путь", () => { const { config, yaml } = renderFor(); - const tampered = yaml.replace("/hui/hysteria2/auth", "/hui/hysteria2/authorize"); + const tampered = yaml.replace("/internal/hysteria/auth", "/internal/hysteria/authorize"); expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/auth\.http\.url must be/); }); @@ -156,7 +156,7 @@ describe("подмены в конфиге обнаруживаются", () => test("сообщение об ошибке auth url не печатает сам токен", () => { const { config, yaml } = renderFor(); - const tampered = yaml.replace("http://127.0.0.1:8080/hui", "http://127.0.0.1:9090/hui"); + const tampered = yaml.replace("http://127.0.0.1:8080/", "http://127.0.0.1:9090/"); try { assertHysteriaConfigMatchesProfile(tampered, config); throw new Error("expected assertion to fail"); diff --git a/orchestrator/test/fixtures.ts b/orchestrator/test/fixtures.ts index 072a302..84b098f 100644 --- a/orchestrator/test/fixtures.ts +++ b/orchestrator/test/fixtures.ts @@ -10,6 +10,7 @@ export const BASELINE_ENV_LINES: readonly string[] = [ "HY2XS_IPV6_ENABLED=false", "HY2XS_DOMAIN=vpn.example.com", "HY2XS_DNS_AAAA_POLICY=strict", + "HY2XS_PUBLIC_ENDPOINT_POLICY=strict", "HY2XS_PUBLIC_HOST=vpn.example.com", "HY2XS_PUBLIC_PORT=443", "HY2XS_SSH_PORT=2323", diff --git a/orchestrator/test/install-boundary.test.ts b/orchestrator/test/install-boundary.test.ts index 086ee79..10b961f 100644 --- a/orchestrator/test/install-boundary.test.ts +++ b/orchestrator/test/install-boundary.test.ts @@ -2,18 +2,29 @@ import { afterEach, describe, expect, test } from "bun:test"; import { classifyFailure } from "../src/commands/install"; import { disableReadOnlyGuard, enableReadOnlyGuard, isReadOnlyGuardActive } from "../src/lib/guard"; import { writeText, writeTextAtomic } from "../src/lib/fs"; -import { runHidden, runRawVisible, runVisible } from "../src/lib/process"; +import { + runMutating, + runMutatingHidden, + runMutatingRaw, + runMutatingVisible, + runReadOnly, + runReadOnlySecret +} from "../src/lib/process"; type Ownership = Parameters[0]; function ownership(overrides: Partial = {}): Ownership { return { stateWritten: false, - depsInstalled: false, - filesystemPrepared: false, - unitsDeployed: false, + depsTouched: false, + filesystemTouched: false, + uiTouched: false, + hysteriaTouched: false, + configTouched: false, + unitsTouched: false, firewallTouched: false, - postInstallWritten: false, + postInstallTouched: false, + bootstrapSecretTouched: false, servicesStarted: false, ...overrides }; @@ -38,9 +49,35 @@ describe("read-only guard (PHASE 0)", () => { test("под guard'ом мутирующие раннеры недоступны", async () => { enableReadOnlyGuard("test phase"); - await expect(runVisible`true`).rejects.toThrow(/read-only guard violation/); - await expect(runHidden`true`).rejects.toThrow(/read-only guard violation/); - await expect(runRawVisible("true")).rejects.toThrow(/read-only guard violation/); + await expect(runMutating`true`).rejects.toThrow(/read-only guard violation/); + await expect(runMutatingVisible`true`).rejects.toThrow(/read-only guard violation/); + await expect(runMutatingHidden`true`).rejects.toThrow(/read-only guard violation/); + await expect(runMutatingRaw("true")).rejects.toThrow(/read-only guard violation/); + }); + + // Регрессия: раньше существовал один универсальный `run`, через который + // мутирующие команды (useradd, install -d, mkdir) проходили мимо guard'а. + // Read-only раннеры обязаны работать под guard'ом, мутирующие — нет. + test("read-only раннеры под guard'ом не блокируются", async () => { + enableReadOnlyGuard("test phase"); + // Проверяется guard, а не наличие POSIX-shell: на машине разработчика без + // `sh` вызов упадёт по ENOENT, и это тоже доказывает, что guard его + // пропустил. Значение имеет только отсутствие guard violation. + for (const call of [ + () => runReadOnly`printf hy2xs`, + () => runReadOnlySecret`printf hy2xs` + ]) { + try { + expect(await call()).toBe("hy2xs"); + } catch (error) { + expect(String(error)).not.toMatch(/read-only guard violation/); + } + } + }); + + test("сообщение мутирующего раннера называет операцию", async () => { + enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); + await expect(runMutating`mktemp -d`).rejects.toThrow(/runMutating\(mktemp -d\).*PHASE 0/s); }); test("сообщение называет операцию и фазу", async () => { @@ -66,26 +103,64 @@ describe("классификация отказа установки", () => { expect(classifyFailure(ownership(), "preflight_ok")).toBe("fatal_pre_apply"); }); - test("установленные пакеты уже делают отказ post-apply", () => { - expect(classifyFailure(ownership({ depsInstalled: true }), "preflight_ok")).toBe("fatal_post_apply"); + // Регрессия: install-state.json пишется сразу после успешного preflight, до + // installDeps. Пока classifyFailure его не учитывал, падение apt-get + // объявлялось «на сервере ничего не изменено», rollback пропускался, а + // /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую + // установку по clean-host контракту. + test("записанный install-state сам по себе делает отказ post-apply", () => { + expect(classifyFailure(ownership({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply"); }); - test("развёрнутые юниты без firewall — post-apply", () => { - expect(classifyFailure(ownership({ filesystemPrepared: true, unitsDeployed: true }), "units_deployed")).toBe( - "fatal_post_apply" - ); + test("падение installDeps после записи состояния — post-apply", () => { + expect( + classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok") + ).toBe("fatal_post_apply"); + }); + + // Флаг взводится ПЕРЕД шагом, поэтому частично применённый apt-get уже + // считается изменением хоста, даже если installDeps не завершился. + test("начатая установка пакетов уже делает отказ post-apply", () => { + expect(classifyFailure(ownership({ depsTouched: true }), "preflight_ok")).toBe("fatal_post_apply"); + }); + + test("каждая мутирующая стадия до firewall даёт post-apply", () => { + const stages = [ + "filesystemTouched", + "uiTouched", + "hysteriaTouched", + "configTouched", + "unitsTouched" + ] as const; + for (const stage of stages) { + expect(classifyFailure(ownership({ [stage]: true }), "installing")).toBe("fatal_post_apply"); + } }); test("тронутый firewall классифицируется как firewall failure", () => { expect( - classifyFailure(ownership({ unitsDeployed: true, firewallTouched: true }), "firewall_applied") + classifyFailure(ownership({ unitsTouched: true, firewallTouched: true }), "firewall_applied") ).toBe("firewall_connectivity_failure"); }); test("после записи post-install env отказ — postinstall validation", () => { expect( classifyFailure( - ownership({ unitsDeployed: true, firewallTouched: true, postInstallWritten: true }), + ownership({ unitsTouched: true, firewallTouched: true, postInstallTouched: true }), + "postinstall_env_written" + ) + ).toBe("postinstall_validation_failure"); + }); + + test("отказ на bootstrap-секрете тоже postinstall validation", () => { + expect( + classifyFailure( + ownership({ + unitsTouched: true, + firewallTouched: true, + postInstallTouched: true, + bootstrapSecretTouched: true + }), "postinstall_env_written" ) ).toBe("postinstall_validation_failure"); @@ -93,9 +168,10 @@ describe("классификация отказа установки", () => { test("после старта сервисов различаются smoke и service failure", () => { const started = ownership({ - unitsDeployed: true, + unitsTouched: true, firewallTouched: true, - postInstallWritten: true, + postInstallTouched: true, + bootstrapSecretTouched: true, servicesStarted: true }); expect(classifyFailure(started, "smoke_running")).toBe("smoke_readiness_timeout"); @@ -107,6 +183,14 @@ describe("классификация отказа установки", () => { // preflight-ошибка со словом "nftables" приводила к откату чужого firewall. test("текст ошибки не влияет на классификацию", () => { expect(classifyFailure(ownership(), "installing")).toBe("fatal_pre_apply"); - expect(classifyFailure(ownership({ depsInstalled: true }), "deps_ok")).toBe("fatal_post_apply"); + expect(classifyFailure(ownership({ depsTouched: true }), "deps_ok")).toBe("fatal_post_apply"); + }); + + // Единственный способ получить fatal_pre_apply — не тронуть вообще ничего. + test("fatal_pre_apply невозможен ни при одном взведённом флаге", () => { + const empty = ownership(); + for (const key of Object.keys(empty) as (keyof typeof empty)[]) { + expect(classifyFailure(ownership({ [key]: true }), "installing")).not.toBe("fatal_pre_apply"); + } }); }); diff --git a/orchestrator/test/network-endpoint.test.ts b/orchestrator/test/network-endpoint.test.ts new file mode 100644 index 0000000..93da77e --- /dev/null +++ b/orchestrator/test/network-endpoint.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "bun:test"; +import { + checkPublicEndpoint, + getLocalPublicIpv4s, + isRoutablePublicIpv4, + type EndpointProbe +} from "../src/steps/networkEndpoint"; +import { assertPublicEndpoint } from "../src/steps/preflight"; +import { baselineConfig } from "./fixtures"; + +/** + * Проба полностью подменяет и DNS, и список локальных адресов: тест не имеет + * права зависеть ни от сети, ни от интерфейсов машины разработчика. + */ +function probe(options: { local: string[]; dns?: Record }): EndpointProbe { + return { + async resolve4(hostname) { + const records = options.dns?.[hostname]; + if (records === undefined || records === "ENODATA") { + const error = new Error(`queryA ENODATA ${hostname}`) as Error & { code?: string }; + error.code = "ENODATA"; + throw error; + } + return records; + }, + getLocalPublicIpv4s: () => options.local + }; +} + +const CURRENT = "185.10.20.27"; +const STALE = "185.10.20.10"; +const SECOND = "185.10.20.28"; + +describe("классификация IPv4", () => { + test("публичные адреса распознаются", () => { + for (const ip of ["1.1.1.1", "8.8.8.8", "185.10.20.27", "203.0.113.5"]) { + expect(isRoutablePublicIpv4(ip)).toBe(true); + } + }); + + test("непубличные диапазоны исключаются", () => { + for (const ip of [ + "0.0.0.0", + "10.1.2.3", + "100.64.0.1", + "127.0.0.1", + "169.254.1.1", + "172.16.0.1", + "172.31.255.254", + "192.168.1.1", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255" + ]) { + expect(isRoutablePublicIpv4(ip), `${ip} должен быть исключён`).toBe(false); + } + }); + + test("172.32.0.0 уже вне приватного диапазона", () => { + expect(isRoutablePublicIpv4("172.32.0.1")).toBe(true); + expect(isRoutablePublicIpv4("172.15.255.255")).toBe(true); + }); + + test("не-IPv4 не проходит", () => { + for (const value of ["", "vpn.example.com", "2001:db8::1", "1.2.3", "1.2.3.256"]) { + expect(isRoutablePublicIpv4(value)).toBe(false); + } + }); + + test("перечисление локальных адресов не падает на реальной машине", () => { + const local = getLocalPublicIpv4s(); + expect(Array.isArray(local)).toBe(true); + for (const ip of local) { + expect(isRoutablePublicIpv4(ip)).toBe(true); + } + }); +}); + +describe("инвариант публичного endpoint", () => { + const config = baselineConfig(); + + test("A-запись совпадает с текущим публичным IPv4 — PASS", async () => { + const problems = await checkPublicEndpoint( + config, + probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } }) + ); + expect(problems).toHaveLength(0); + }); + + // Основной сценарий: провайдер принудительно сменил IP, DNS не обновили. + test("A-запись указывает на старый IPv4 — FAIL с обоими адресами в тексте", async () => { + const problems = await checkPublicEndpoint( + config, + probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].kind).toBe("mismatch"); + expect(problems[0].message).toContain(STALE); + expect(problems[0].message).toContain(CURRENT); + expect(problems[0].message).toContain("Update the DNS A record"); + }); + + test("A-запись отсутствует — FAIL", async () => { + const problems = await checkPublicEndpoint(config, probe({ local: [CURRENT], dns: {} })); + expect(problems).toHaveLength(1); + expect(problems[0].kind).toBe("resolution"); + expect(problems[0].message).toContain("has no A-record"); + }); + + // Единственный правильный адрес рядом с чужим — это второй backend за тем + // же именем. Для single-host профиля это ошибка, а не балансировка. + test("A = текущий + чужой — FAIL", async () => { + const problems = await checkPublicEndpoint( + config, + probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT, "1.1.1.1"] } }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].kind).toBe("mismatch"); + }); + + test("у сервера два публичных IP, DNS использует один — PASS", async () => { + const problems = await checkPublicEndpoint( + config, + probe({ local: [CURRENT, SECOND], dns: { "vpn.example.com": [CURRENT] } }) + ); + expect(problems).toHaveLength(0); + }); + + test("PUBLIC_HOST как правильный IPv4-литерал — PASS", async () => { + const literal = baselineConfig({ HY2XS_PUBLIC_HOST: CURRENT, HY2XS_DOMAIN: "vpn.example.com" }); + const problems = await checkPublicEndpoint( + literal, + probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } }) + ); + expect(problems).toHaveLength(0); + }); + + test("PUBLIC_HOST как устаревший IPv4-литерал — FAIL", async () => { + const literal = baselineConfig({ HY2XS_PUBLIC_HOST: STALE, HY2XS_DOMAIN: "vpn.example.com" }); + const problems = await checkPublicEndpoint( + literal, + probe({ local: [CURRENT], dns: { "vpn.example.com": [CURRENT] } }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].message).toContain(STALE); + }); + + /** + * Ключевой пробел исходной проверки: проверялся HY2XS_DOMAIN, а в + * hysteria2:// уходит HY2XS_PUBLIC_HOST. + */ + test("DOMAIN в порядке, но отдельный PUBLIC_HOST устарел — FAIL", async () => { + const split = baselineConfig({ + HY2XS_DOMAIN: "tls.example.com", + HY2XS_PUBLIC_HOST: "vpn.example.com" + }); + const problems = await checkPublicEndpoint( + split, + probe({ + local: [CURRENT], + dns: { "tls.example.com": [CURRENT], "vpn.example.com": [STALE] } + }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].message).toContain("HY2XS_PUBLIC_HOST"); + }); + + test("PUBLIC_HOST в порядке, но отдельный TLS-домен устарел — FAIL", async () => { + const split = baselineConfig({ + HY2XS_DOMAIN: "tls.example.com", + HY2XS_PUBLIC_HOST: "vpn.example.com" + }); + const problems = await checkPublicEndpoint( + split, + probe({ + local: [CURRENT], + dns: { "tls.example.com": [STALE], "vpn.example.com": [CURRENT] } + }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].message).toContain("HY2XS_DOMAIN"); + }); + + test("совпадающие DOMAIN и PUBLIC_HOST проверяются один раз", async () => { + const problems = await checkPublicEndpoint( + config, + probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } }) + ); + expect(problems).toHaveLength(1); + }); + + test("на сервере нет ни одного публичного IPv4 — FAIL", async () => { + const problems = await checkPublicEndpoint( + config, + probe({ local: [], dns: { "vpn.example.com": [CURRENT] } }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].kind).toBe("no_local_address"); + }); + + // Регрессия: пока резолв выполнялся только после проверки локального + // множества, сервер за NAT скрывал отсутствие A-записи. + test("отсутствие A-записи видно даже без локального публичного IPv4", async () => { + const problems = await checkPublicEndpoint(config, probe({ local: [], dns: {} })); + expect(problems.map((problem) => problem.kind)).toEqual(["resolution", "no_local_address"]); + }); + + test("без локального множества не возникает каскада mismatch по каждому имени", async () => { + const split = baselineConfig({ + HY2XS_DOMAIN: "tls.example.com", + HY2XS_PUBLIC_HOST: "vpn.example.com" + }); + const problems = await checkPublicEndpoint( + split, + probe({ local: [], dns: { "tls.example.com": [CURRENT], "vpn.example.com": [CURRENT] } }) + ); + expect(problems).toHaveLength(1); + expect(problems[0].kind).toBe("no_local_address"); + }); +}); + +describe("HY2XS_PUBLIC_ENDPOINT_POLICY", () => { + test("strict — расхождение останавливает операцию", async () => { + const strict = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "strict" }); + await expect( + assertPublicEndpoint(strict, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) + ).rejects.toThrow(/DNS IPv4 mismatch/); + }); + + test("strict — значение по умолчанию", async () => { + const implicit = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: null }); + expect(implicit.publicEndpointPolicy).toBe("strict"); + await expect( + assertPublicEndpoint(implicit, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) + ).rejects.toThrow(/DNS IPv4 mismatch/); + }); + + test("warn — расхождение пропускается", async () => { + const warn = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "warn" }); + await expect( + assertPublicEndpoint(warn, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) + ).resolves.toBeUndefined(); + }); + + test("off — сравнение не выполняется", async () => { + const off = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "off" }); + await expect( + assertPublicEndpoint(off, probe({ local: [], dns: { "vpn.example.com": [STALE] } })) + ).resolves.toBeUndefined(); + await expect( + assertPublicEndpoint(off, probe({ local: [CURRENT], dns: { "vpn.example.com": [STALE] } })) + ).resolves.toBeUndefined(); + }); + + // Ослаблять нечего: имя без A-записи не работает ни в какой топологии. + test("отсутствие A-записи фатально при любой политике", async () => { + for (const policy of ["strict", "warn", "off"]) { + const config = baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: policy }); + await expect( + assertPublicEndpoint(config, probe({ local: [CURRENT], dns: {} })) + ).rejects.toThrow(/has no A-record/); + } + }); + + test("неизвестное значение политики отклоняется", () => { + expect(() => baselineConfig({ HY2XS_PUBLIC_ENDPOINT_POLICY: "maybe" })).toThrow( + /invalid HY2XS_PUBLIC_ENDPOINT_POLICY/ + ); + }); +}); diff --git a/orchestrator/test/redaction.test.ts b/orchestrator/test/redaction.test.ts index 020c65e..a952168 100644 --- a/orchestrator/test/redaction.test.ts +++ b/orchestrator/test/redaction.test.ts @@ -25,7 +25,7 @@ acme: auth: type: http http: - url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=${MACHINE_TOKEN} + url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN} insecure: false obfs: @@ -61,7 +61,7 @@ describe("редакция серверного конфига", () => { expect(parsed.listen).toBe("0.0.0.0:443"); const auth = parsed.auth as Record; const http = auth.http as Record; - expect(String(http.url)).toContain("127.0.0.1:8080/hui/hysteria2/auth"); + expect(String(http.url)).toContain("127.0.0.1:8080/internal/hysteria/auth"); // В query-параметре маркер приходит percent-encoded — так же, как это // делает Go-санитайзер админки: результат обязан остаться валидным URL. expect(String(http.url)).toContain(encodeURIComponent(REDACTED)); @@ -99,6 +99,45 @@ describe("редакция серверного конфига", () => { expect(dns.name).toBe("cloudflare"); }); + // Паритет с Go-санитайзером админки: URL-значение определяется по самому + // значению, а не по имени ключа. Обе реализации описывают один контракт и + // не имеют права расходиться. + test("URL под произвольным именем ключа теряет секреты, но сохраняет адрес", () => { + const yaml = + "future:\n" + + " endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token\n" + + " mirrors:\n" + + " - https://mirror-user:mirror-p4ss@mirror.example.com/pull\n"; + const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record; + const future = parsed.future as Record; + + const endpoint = String(future.endpoint); + expect(endpoint).toContain("relay.example.com"); + expect(endpoint).not.toContain("svc-p4ss"); + expect(endpoint).not.toContain("endpoint-token"); + + const mirrors = future.mirrors as string[]; + expect(mirrors).toHaveLength(1); + expect(mirrors[0]).toContain("mirror.example.com"); + expect(mirrors[0]).not.toContain("mirror-p4ss"); + }); + + test("не-URL скаляры проходят редакцию без изменений", () => { + const yaml = + "bandwidth:\n up: 50 mbps\n down: 50 mbps\n" + + "quic:\n maxIdleTimeout: 30s\n initStreamReceiveWindow: 8388608\n" + + "outbounds:\n - addr: 10.0.0.1:1080\n"; + const parsed = Bun.YAML.parse(redactYaml(yaml)) as Record; + const bandwidth = parsed.bandwidth as Record; + const quic = parsed.quic as Record; + const outbounds = parsed.outbounds as Record[]; + + expect(bandwidth.up).toBe("50 mbps"); + expect(quic.maxIdleTimeout).toBe("30s"); + expect(quic.initStreamReceiveWindow).toBe(8388608); + expect(outbounds[0].addr).toBe("10.0.0.1:1080"); + }); + test("невалидный YAML не роняет редакцию и всё равно чистится", () => { const broken = `auth:\n http:\n url: http://127.0.0.1:8080/x?access_token=${MACHINE_TOKEN}\n\t bad-tab: [`; const redacted = redactYaml(broken); @@ -116,7 +155,7 @@ describe("редакция env-артефактов", () => { const POST_INSTALL = [ "PACKAGE_VERSION=1.0.0", "HY2_AUTH_MODE=http", - `HY2_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth?access_token=${MACHINE_TOKEN}`, + `HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth?access_token=${MACHINE_TOKEN}`, `HY2_OBFS_PASSWORD=${OBFS_PASSWORD}`, "HY2_PORT=443" ].join("\n"); @@ -126,7 +165,7 @@ describe("редакция env-артефактов", () => { test("секрет внутри URL-значения вырезается, даже если имя ключа несекретное", () => { const redacted = redactEnv(POST_INSTALL); expect(redacted).not.toContain(MACHINE_TOKEN); - expect(redacted).toContain("HY2_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth"); + expect(redacted).toContain("HY2_AUTH_URL=http://127.0.0.1:8080/internal/hysteria/auth"); }); test("ключи-секреты вырезаются по имени", () => { diff --git a/orchestrator/test/render-config.test.ts b/orchestrator/test/render-config.test.ts index 84c8420..05cac4a 100644 --- a/orchestrator/test/render-config.test.ts +++ b/orchestrator/test/render-config.test.ts @@ -129,7 +129,7 @@ describe("шаблон", () => { test("auth URL содержит machine access token", () => { expect(render()).toContain( - "/hui/hysteria2/auth?access_token=traffic-stats-secret" + "/internal/hysteria/auth?access_token=traffic-stats-secret" ); }); diff --git a/orchestrator/tools/print-contract.ts b/orchestrator/tools/print-contract.ts index 8827723..0314993 100644 --- a/orchestrator/tools/print-contract.ts +++ b/orchestrator/tools/print-contract.ts @@ -8,17 +8,22 @@ */ import { + ADMIN_API_BASE, HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE, HY2XS_TARGET_ARCH, - HY2XS_TARGET_DEBIAN_VERSION + HY2XS_TARGET_DEBIAN_VERSION, + HYSTERIA_MACHINE_AUTH_PATH } from "../src/config/profile"; const lines = [ `HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}`, `HY2XS_RELEASE_LINE=${HY2XS_RELEASE_LINE}`, `HY2XS_TARGET_OS_VERSION=${HY2XS_TARGET_DEBIAN_VERSION}`, - `HY2XS_TARGET_ARCH=${HY2XS_TARGET_ARCH}` + `HY2XS_TARGET_ARCH=${HY2XS_TARGET_ARCH}`, + // API-контракт: сборка сверяет эти значения с шаблонами и с константами Go. + `HY2XS_ADMIN_API_BASE=${ADMIN_API_BASE}`, + `HY2XS_HYSTERIA_MACHINE_AUTH_PATH=${HYSTERIA_MACHINE_AUTH_PATH}` ]; process.stdout.write(`${lines.join("\n")}\n`); diff --git a/package/config/hy2xs.env b/package/config/hy2xs.env index 0f924d5..578c1de 100644 --- a/package/config/hy2xs.env +++ b/package/config/hy2xs.env @@ -5,6 +5,12 @@ HY2XS_CONFIG_SCHEMA_VERSION=2 HY2XS_IPV6_ENABLED=false HY2XS_DOMAIN=fi.api.withen.pro HY2XS_DNS_AAAA_POLICY=strict +# strict | warn | off. Проверка того, что A-записи публичного endpoint ведут на +# публичные IPv4 ЭТОГО сервера. Ослаблять только для топологий вне baseline +# (NAT, floating IP): при warn/off устаревший DNS перестаёт останавливать +# установку и doctor, а клиентская ссылка может вести на другую машину. +# Отсутствие A-записи остаётся фатальным при любом значении. +HY2XS_PUBLIC_ENDPOINT_POLICY=strict HY2XS_PUBLIC_HOST=fi.api.withen.pro HY2XS_PUBLIC_PORT=443 HY2XS_SSH_PORT=2323 diff --git a/package/templates/env/post-install.env.tpl b/package/templates/env/post-install.env.tpl index 6b573b7..e7260d5 100644 --- a/package/templates/env/post-install.env.tpl +++ b/package/templates/env/post-install.env.tpl @@ -30,7 +30,7 @@ HY2_TLS_KEY_PATH={{TLS_KEY_PATH}} HY2_LISTEN_HOST={{HYSTERIA_BIND_HOST}} HY2_PORT={{HYSTERIA_PORT}} HY2_AUTH_MODE=http -HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}} +HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}} HY2_TRAFFIC_STATS_LISTEN={{HYSTERIA_API_HOST}}:{{HYSTERIA_API_PORT}} HY2_OBFS_TYPE={{OBFS_TYPE}} HY2_OBFS_PASSWORD={{HYSTERIA_OBFS_PASSWORD}} diff --git a/package/templates/hysteria/config.yaml.tpl b/package/templates/hysteria/config.yaml.tpl index 0ac47bb..a479926 100644 --- a/package/templates/hysteria/config.yaml.tpl +++ b/package/templates/hysteria/config.yaml.tpl @@ -6,7 +6,7 @@ listen: {{HYSTERIA_BIND_HOST}}:{{HYSTERIA_PORT}} auth: type: http http: - url: http://127.0.0.1:{{UI_PORT}}/hui/hysteria2/auth?access_token={{HYSTERIA_API_SECRET}} + url: http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}} insecure: {{AUTH_INSECURE}} {{OBFS_BLOCK}} diff --git a/tools/legacy/purge-v0.sh b/tools/legacy/purge-v0.sh index 639c084..23fda24 100644 --- a/tools/legacy/purge-v0.sh +++ b/tools/legacy/purge-v0.sh @@ -21,20 +21,28 @@ set -euo pipefail APPLY="false" CONFIRMED="false" -KEEP_HYSTERIA_BINARY="false" log() { printf '[hy2xs-purge] %s\n' "$*"; } warn() { printf '[hy2xs-purge] WARNING: %s\n' "$*" >&2; } fail() { printf '[hy2xs-purge] ERROR: %s\n' "$*" >&2; exit 1; } +# Флага «оставить бинарник Hysteria» здесь нет намеренно. +# +# /usr/local/bin/hysteria входит в список legacy-маркеров clean-host +# контракта установщика. Скрипт, который оставлял бы его и при этом печатал +# «хост чист для установки HY2XS v1», прямо противоречил бы следующему +# запуску install.sh: тот отказался бы ставиться. +# +# Полезного сценария у флага тоже не было: свежая установка всё равно кладёт +# собственную проверенную по SHA-256 версию Hysteria. + usage() { cat >&2 <<'EOF' -Usage: purge-v0.sh [--apply --yes-i-know] [--keep-hysteria-binary] +Usage: purge-v0.sh [--apply --yes-i-know] - (без флагов) показать план очистки и выйти - --apply выполнить очистку - --yes-i-know подтверждение: обязателен вместе с --apply - --keep-hysteria-binary не удалять /usr/local/bin/hysteria + (без флагов) показать план очистки и выйти + --apply выполнить очистку + --yes-i-know подтверждение: обязателен вместе с --apply Скрипт удаляет службы, приложение, конфигурацию и БАЗУ ДАННЫХ админки. EOF @@ -46,7 +54,6 @@ parse_args() { case "$1" in --apply) APPLY="true"; shift ;; --yes-i-know) CONFIRMED="true"; shift ;; - --keep-hysteria-binary) KEEP_HYSTERIA_BINARY="true"; shift ;; -h|--help) usage ;; *) fail "unknown argument: $1" ;; esac @@ -120,11 +127,7 @@ show_plan() { fi done - if [ "$KEEP_HYSTERIA_BINARY" = "true" ]; then - log "/usr/local/bin/hysteria будет сохранён (--keep-hysteria-binary)" - else - log "будет удалён /usr/local/bin/hysteria" - fi + log "будет удалён /usr/local/bin/hysteria" log "из /etc/nftables.conf будет убрана строка include для $NFT_FRAGMENT" warn "БАЗА ДАННЫХ админки (/var/lib/hy2xs-admin) удаляется: пиры и их ссылки будут потеряны" @@ -164,9 +167,7 @@ remove_paths() { rm -rf "$path" done - if [ "$KEEP_HYSTERIA_BINARY" != "true" ]; then - rm -f /usr/local/bin/hysteria - fi + rm -f /usr/local/bin/hysteria } # Из /etc/nftables.conf убирается только include HY2XS: остальной ruleset @@ -202,7 +203,7 @@ verify_clean_host() { [ -e "$path" ] && leftovers+=("$path") done - if [ "$KEEP_HYSTERIA_BINARY" != "true" ] && [ -e /usr/local/bin/hysteria ]; then + if [ -e /usr/local/bin/hysteria ]; then leftovers+=(/usr/local/bin/hysteria) fi