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:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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("ключи-секреты вырезаются по имени", () => {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user