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;
+55 -1
View File
@@ -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",
+3 -3
View File
@@ -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");
+1
View File
@@ -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",
+103 -19
View File
@@ -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<typeof classifyFailure>[0];
function ownership(overrides: Partial<Ownership> = {}): 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");
}
});
});
+270
View File
@@ -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<string, string[] | "ENODATA"> }): 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/
);
});
});
+43 -4
View File
@@ -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<string, unknown>;
const http = auth.http as Record<string, unknown>;
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<string, unknown>;
const future = parsed.future as Record<string, unknown>;
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<string, unknown>;
const bandwidth = parsed.bandwidth as Record<string, unknown>;
const quic = parsed.quic as Record<string, unknown>;
const outbounds = parsed.outbounds as Record<string, unknown>[];
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("ключи-секреты вырезаются по имени", () => {
+1 -1
View File
@@ -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"
);
});
+7 -2
View File
@@ -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`);
+6
View File
@@ -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
+1 -1
View File
@@ -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}}
+1 -1
View File
@@ -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}}
+12 -11
View File
@@ -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
Скрипт удаляет службы, приложение, конфигурацию и БАЗУ ДАННЫХ админки.
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 "из /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
}
# Из /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