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;
|
||||
|
||||
Reference in New Issue
Block a user