fix(install): сделать границу «хост изменён» настоящим инвариантом

fatal_pre_apply мог означать «хост уже изменён». install-state.json пишется
сразу после успешного preflight, до установки пакетов, но классификация
отказа его не учитывала. Падение apt-get объявлялось как «на сервере ничего
не изменено»: откат и обработка состояния пропускались, а маркер оставался на
диске и ломал следующую установку по clean-host контракту.

Ownership-флаги переформулированы с «шаг успешно завершился» на «операция
могла начать менять систему» и взводятся перед мутирующим вызовом: apt-get
умеет изменить систему и упасть. fatal_pre_apply теперь недостижим ни при
одном взведённом флаге, включая stateWritten.

Read-only guard PHASE 0 можно было обойти. Guard стоял на writeText,
writeTextAtomic, runVisible, runHidden и runRawVisible, но не на универсальном
run, через который в коде проходили и наблюдение (ss, systemctl is-active), и
настоящие мутации (useradd, install -d, mkdir, cp -a, tar). Универсального
раннера больше нет: runReadOnly/runReadOnlySecret без guard'а и runMutating*
под guard'ом, выбор — явное решение на месте вызова.

clean-host не замечал часть того, что удаляет purge. /var/lib/hysteria с
ACME-состоянием Hysteria, /var/log/hy2xs, /usr/local/lib/hy2xs и
/usr/local/bin/hy2xs-orchestrator не были маркерами: сервер, где остался
только старый runtime-state Hysteria, проходил проверку и получал свежую
установку поверх чужого состояния. Пути, которые install.sh создаёт между
фазами, помечены как созданные установщиком, иначе PHASE 1 отказала бы на
собственном оркестраторе.

purge-v0.sh --keep-hysteria-binary противоречил установщику: скрипт сохранял
/usr/local/bin/hysteria и сообщал «хост чист для установки HY2XS v1», хотя
clean-host считает этот бинарник legacy-маркером. Флаг удалён.

DNS проверялся на существование A-записи, но не на то, куда она ведёт. После
принудительной смены IPv4 провайдером doctor отвечал успехом, хотя клиентская
ссылка отправляла людей на чужую машину. Проверялся при этом HY2XS_DOMAIN,
тогда как в hysteria2:// уезжает HY2XS_PUBLIC_HOST.

Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать
множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка
живёт в общем preflight, поэтому действует в install, reconfigure и doctor.
Адрес определяется локально, без внешних сервисов определения IP. Строгость
управляется HY2XS_PUBLIC_ENDPOINT_POLICY (strict по умолчанию); отсутствие
A-записи фатально при любом значении.

TS-санитайзер приведён к той же формулировке, что и Go: URL-значение
определяется по самому значению, а не по имени ключа.
This commit is contained in:
2026-08-27 20:49:38 +05:00
parent b52fac1394
commit a88268b0cd
37 changed files with 1161 additions and 293 deletions
+15 -15
View File
@@ -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<void>
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<void>
// 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}`);
}
+2 -2
View File
@@ -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<void> {
}
try {
const sshdConfigText = await run`sshd -T`;
const sshdConfigText = await runReadOnly`sshd -T`;
const lines = sshdConfigText.split(/\r?\n/);
const effective = new Map<string, string>();
for (const line of lines) {
+58 -25
View File
@@ -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<void> {
@@ -215,8 +244,8 @@ export async function install(options: InstallOptions): Promise<void> {
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<void> {
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<void> {
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<void> {
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");
+23 -23
View File
@@ -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<void> {
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<void> {
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<InstallStateRecord | null> {
+4 -4
View File
@@ -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<string> {
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<string> {
async function firewallState(): Promise<string> {
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<void> {
}
}
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");
+16 -1
View File
@@ -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}`,
+21
View File
@@ -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. */
+2 -2
View File
@@ -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<string> {
try {
@@ -11,7 +11,7 @@ export async function readPackageValue(packageDir: string, file: string, fallbac
export async function readInstalledHysteriaVersion(): Promise<string> {
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 {
+61 -26
View File
@@ -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<string> {
const rendered = renderCommand(command, args);
async function capture(rendered: string, includeCommandInError: boolean): Promise<string> {
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<void> {
/**
* Наблюдение за системой: вывод возвращается вызывающему, хост не меняется.
* Команда попадает в текст ошибки — она не содержит секретов по построению.
*/
export async function runReadOnly(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
return capture(renderCommand(command, args), true);
}
/**
* То же, но команда НЕ попадает в текст ошибки: аргументы несут секреты
* (machine token, пароль пира), а сообщение уходит в логи и диагностику.
*/
export async function runReadOnlySecret(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
return capture(renderCommand(command, args), false);
}
/** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */
export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise<string> {
const rendered = renderCommand(command, args);
assertMutationAllowed(`runVisible(${rendered})`);
assertMutationAllowed(`runMutating(${rendered})`);
return capture(rendered, true);
}
/** Мутация с эхом команды в лог и прямым выводом подпроцесса. */
export async function runMutatingVisible(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
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<void> {
assertMutationAllowed("runRawVisible(...)");
/** Мутация многострочным скриптом (`sh -eu -c`). */
export async function runMutatingRaw(command: string): Promise<void> {
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<void> {
}
}
export async function runHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
/** Мутация без эха команды: аргументы могут содержать секреты. */
export async function runMutatingHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
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<string> {
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();
}
+3 -10
View File
@@ -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;
+6 -6
View File
@@ -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<string, string> {
async function commandExists(command: string): Promise<boolean> {
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<boolean> {
async function detectOpenSsl3(): Promise<boolean> {
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<PlatformProfile> {
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");
+27 -2
View File
@@ -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<string>();
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;
+10 -10
View File
@@ -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<void> {
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}`;
}
}
+5 -4
View File
@@ -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=<machine token>"
`${HYSTERIA_MACHINE_AUTH_PATH}?access_token=<machine token>`
);
}
+3 -3
View File
@@ -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<void> {
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`;
}
+6 -6
View File
@@ -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}`;
}
+7 -7
View File
@@ -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<boolean> {
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<boolean> {
}
async function getUserField(user: string, field: number): Promise<string> {
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<string> {
return (await run`id -gn ${user}`).trim();
return (await runMutating`id -gn ${user}`).trim();
}
async function ensureDir(path: string, mode: string, ownerGroup: string): Promise<void> {
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<void> {
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<void> {
@@ -58,5 +58,5 @@ export async function prepareFilesystem(context: InstallContext): Promise<void>
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}`;
}
+23 -23
View File
@@ -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<void> {
await runVisible`mkdir -p ${rollbackRoot(opId)}`;
await runMutatingVisible`mkdir -p ${rollbackRoot(opId)}`;
}
async function cleanupFirewallBackupFiles(opId: string): Promise<void> {
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<void> {
}
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<v
if (context.config.firewallStagedApply) {
const unit = rollbackUnit(opId);
await runVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
await runVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
}
await cleanupFirewallBackupFiles(opId);
@@ -199,12 +199,12 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
if (context.config.firewallStagedApply) {
const unit = rollbackUnit(opId);
await runVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
await runVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
}
await runVisible`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 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);
}
+9 -9
View File
@@ -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<void> {
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;
+230
View File
@@ -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<string[]>;
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<string>();
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<string[]> {
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<EndpointProblem[]> {
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;
}
+49 -17
View File
@@ -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<void> {
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<boolean> {
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<boolean> {
async function isUdpPortListening(port: number): Promise<boolean> {
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<boolean> {
async function isUnitActive(unit: string): Promise<boolean> {
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);
+53 -43
View File
@@ -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<T>(
}
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<void> {
}
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<void> {
"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<void> {
"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<void> {
"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<void> {
"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<void> {
"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<void> {
"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<void> {
"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<void> {
"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();
+3 -3
View File
@@ -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<void> {
const values = {
@@ -16,6 +16,6 @@ export async function deploySystemd(context: RuntimeContext): Promise<void> {
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`;
}
+4 -4
View File
@@ -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<void> {
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}`;
}
+13
View File
@@ -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;