feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin
Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1. Build: - версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и замораживается в metadata пакета (version + immutable url + sha256); - compatibility gate: реальный бинарник должен принять канонический конфиг HY2XS для gecko и salamander до создания пакета; - сборка прогоняет тесты оркестратора и админки. Конфигурационный контракт: - HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast; - obfs стал настоящим union gecko|salamander, gecko — default; - obfs-блок рендерится оркестратором целиком, два подтипа одновременно структурно невозможны; - современный baseline: congestion bbr/standard, disableLossCompensation=false, disableStatelessReset=false, полный quic-блок. Исправления: - share URI для gecko: генератор был завязан на Obfs.Salamander.Password и выдавал нерабочую ссылку при любой другой обфускации; - SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file; - экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль; - экспорт терял неизвестные upstream-поля при round-trip через типизированную модель; - renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом; - namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0). Тесты: - 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover; - тесты URI и экспорта в Go; - tools/test/e2e-hysteria.sh с реальным клиентом Hysteria. UX: - подсказки и примеры в форме создания пира. Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов), документация на русском.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Build-time логика выбора upstream-релиза Hysteria.
|
||||
*
|
||||
* Этот модуль не входит в install-only orchestrator: target-сервер никогда
|
||||
* не резолвит "latest" сам, он получает уже замороженные version/url/sha256.
|
||||
*/
|
||||
|
||||
export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria";
|
||||
export const HYSTERIA_LINUX_AMD64_ASSET = "hysteria-linux-amd64";
|
||||
|
||||
/** Upstream публикует несколько семейств тегов; серверный бинарник живёт в app/vX.Y.Z. */
|
||||
export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/;
|
||||
export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
export type GithubAsset = {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
};
|
||||
|
||||
export type GithubRelease = {
|
||||
tag_name: string;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
assets?: GithubAsset[];
|
||||
};
|
||||
|
||||
export type ResolvedHysteriaRelease = {
|
||||
/** Нормализованная версия вида v2.12.2. */
|
||||
version: string;
|
||||
/** Полный upstream-тег вида app/v2.12.2. */
|
||||
tag: string;
|
||||
/** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */
|
||||
artifactUrl: string;
|
||||
publishedAt: string;
|
||||
};
|
||||
|
||||
type SemverParts = [number, number, number];
|
||||
|
||||
export function parseVersion(value: string): SemverParts | null {
|
||||
const match = HYSTERIA_VERSION_PATTERN.exec(value.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
export function parseAppTag(tag: string): { version: string; parts: SemverParts } | null {
|
||||
const match = HYSTERIA_APP_TAG_PATTERN.exec(tag.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const parts: SemverParts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
return { version: `v${parts[0]}.${parts[1]}.${parts[2]}`, parts };
|
||||
}
|
||||
|
||||
/** Числовое сравнение, а не лексикографическое: v2.9.10 новее v2.9.2. */
|
||||
export function compareVersionParts(a: SemverParts, b: SemverParts): number {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isStable(release: GithubRelease): boolean {
|
||||
return release.draft !== true && release.prerelease !== true;
|
||||
}
|
||||
|
||||
export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset {
|
||||
const matches = (release.assets ?? []).filter((asset) => asset.name === HYSTERIA_LINUX_AMD64_ASSET);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(
|
||||
`upstream release ${release.tag_name} has no ${HYSTERIA_LINUX_AMD64_ASSET} asset`
|
||||
);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`upstream release ${release.tag_name} has ${matches.length} ambiguous ${HYSTERIA_LINUX_AMD64_ASSET} assets`
|
||||
);
|
||||
}
|
||||
|
||||
const url = matches[0].browser_download_url?.trim();
|
||||
if (!url) {
|
||||
throw new Error(`upstream release ${release.tag_name} has an empty asset download url`);
|
||||
}
|
||||
if (!url.startsWith("https://")) {
|
||||
throw new Error(`upstream release ${release.tag_name} asset url is not https: ${url}`);
|
||||
}
|
||||
|
||||
return { name: matches[0].name, browser_download_url: url };
|
||||
}
|
||||
|
||||
function toResolved(release: GithubRelease, version: string): ResolvedHysteriaRelease {
|
||||
return {
|
||||
version,
|
||||
tag: release.tag_name.trim(),
|
||||
artifactUrl: selectLinuxAmd64Asset(release).browser_download_url,
|
||||
publishedAt: (release.published_at ?? "").trim()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает последний стабильный app-релиз: без draft и prerelease, тег строго
|
||||
* app/vX.Y.Z, наибольшая версия по числовому сравнению.
|
||||
*/
|
||||
export function selectLatestStableRelease(releases: GithubRelease[]): ResolvedHysteriaRelease {
|
||||
let best: { release: GithubRelease; version: string; parts: SemverParts } | null = null;
|
||||
|
||||
for (const release of releases) {
|
||||
if (!isStable(release)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseAppTag(release.tag_name ?? "");
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (!best || compareVersionParts(parsed.parts, best.parts) > 0) {
|
||||
best = { release, version: parsed.version, parts: parsed.parts };
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
throw new Error(
|
||||
`no stable ${HYSTERIA_UPSTREAM_REPO} release matching app/vX.Y.Z found in upstream release list`
|
||||
);
|
||||
}
|
||||
|
||||
return toResolved(best.release, best.version);
|
||||
}
|
||||
|
||||
/** Выбирает конкретную версию по HYSTERIA_VERSION_OVERRIDE. */
|
||||
export function selectReleaseByVersion(
|
||||
releases: GithubRelease[],
|
||||
version: string
|
||||
): ResolvedHysteriaRelease {
|
||||
const requested = parseVersion(version);
|
||||
if (!requested) {
|
||||
throw new Error(`invalid Hysteria version override: ${version} (expected vX.Y.Z)`);
|
||||
}
|
||||
|
||||
const normalized = `v${requested[0]}.${requested[1]}.${requested[2]}`;
|
||||
|
||||
for (const release of releases) {
|
||||
const parsed = parseAppTag(release.tag_name ?? "");
|
||||
if (parsed?.version === normalized) {
|
||||
if (release.draft === true) {
|
||||
throw new Error(`Hysteria version override ${normalized} points to a draft release`);
|
||||
}
|
||||
return toResolved(release, parsed.version);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Hysteria version override ${normalized} was not found in ${HYSTERIA_UPSTREAM_REPO}`);
|
||||
}
|
||||
|
||||
export function renderLockEnv(
|
||||
resolved: ResolvedHysteriaRelease,
|
||||
sha256: string,
|
||||
resolution: string,
|
||||
resolvedAt: string
|
||||
): string {
|
||||
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error(`invalid Hysteria artifact sha256: ${sha256}`);
|
||||
}
|
||||
|
||||
return [
|
||||
"# Файл генерируется tools/build/lib/hysteria.sh.",
|
||||
"# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.",
|
||||
`HYSTERIA_VERSION=${resolved.version}`,
|
||||
`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`,
|
||||
`HYSTERIA_ARTIFACT_SHA256=${sha256}`,
|
||||
`HYSTERIA_RESOLUTION=${resolution}`,
|
||||
`HYSTERIA_RESOLVED_AT=${resolvedAt}`,
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
@@ -69,7 +69,8 @@ export async function doctor(options: ReconfigureOptions): Promise<void> {
|
||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
||||
installDate: new Date().toISOString(),
|
||||
hysteriaVersion: await readInstalledHysteriaVersion()
|
||||
hysteriaVersion: await readInstalledHysteriaVersion(),
|
||||
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
|
||||
};
|
||||
|
||||
step("doctor preflight");
|
||||
|
||||
@@ -159,6 +159,7 @@ export async function install(options: InstallOptions): Promise<void> {
|
||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
||||
installDate: new Date().toISOString(),
|
||||
hysteriaVersion: "unknown",
|
||||
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown"),
|
||||
hysteriaTargetVersion: await readPackageValue(options.packageDir, "hysteria.version", ""),
|
||||
hysteriaArtifactUrl: await readPackageValue(options.packageDir, "hysteria.url", ""),
|
||||
hysteriaArtifactSha256: await readPackageValue(options.packageDir, "hysteria.sha256", "")
|
||||
|
||||
@@ -176,7 +176,8 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
||||
installDate: new Date().toISOString(),
|
||||
hysteriaVersion: await readInstalledHysteriaVersion()
|
||||
hysteriaVersion: await readInstalledHysteriaVersion(),
|
||||
hysteriaResolution: await readPackageValue(options.packageDir, "hysteria.resolution", "unknown")
|
||||
};
|
||||
|
||||
step("preflight");
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FirewallMode, RuntimeConfig, TlsMode } from "../types/context";
|
||||
import {
|
||||
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
||||
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
||||
HY2XS_CONFIG_SCHEMA_VERSION,
|
||||
normalizeHysteriaObfsType,
|
||||
validateGeckoPacketSizes
|
||||
} from "./profile";
|
||||
|
||||
type EnvMap = Record<string, string>;
|
||||
|
||||
@@ -143,12 +150,22 @@ function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" {
|
||||
return "http";
|
||||
}
|
||||
|
||||
function normalizeFixedHysteriaObfsType(value: string | undefined): "salamander" {
|
||||
const obfsType = value || "salamander";
|
||||
if (obfsType !== "salamander") {
|
||||
throw new Error("HY2XS_HYSTERIA_OBFS_TYPE is fixed in HY2XS production profile: salamander");
|
||||
function normalizeConfigSchemaVersion(value: string | undefined): number {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) {
|
||||
return HY2XS_CONFIG_SCHEMA_VERSION;
|
||||
}
|
||||
return "salamander";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`invalid HY2XS_CONFIG_SCHEMA_VERSION: ${value}`);
|
||||
}
|
||||
if (parsed !== HY2XS_CONFIG_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`unsupported HY2XS_CONFIG_SCHEMA_VERSION: ${parsed}. This package understands schema ${HY2XS_CONFIG_SCHEMA_VERSION}; ` +
|
||||
"HY2XS v1 requires a clean installation and does not migrate v0 configuration in place."
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeYamlSafeSecret(name: string, value: string): string {
|
||||
@@ -191,9 +208,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
const dnsAaaaPolicy = normalizeDnsAaaaPolicy(env.HY2XS_DNS_AAAA_POLICY);
|
||||
const firewallMode = normalizeFirewallMode(env);
|
||||
const hysteriaAuthMode = normalizeFixedHysteriaAuthMode(env.HY2XS_HYSTERIA_AUTH_MODE);
|
||||
const hysteriaObfsType = normalizeFixedHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
|
||||
const hysteriaObfsType = normalizeHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
|
||||
|
||||
const config: RuntimeConfig = {
|
||||
configSchemaVersion: normalizeConfigSchemaVersion(env.HY2XS_CONFIG_SCHEMA_VERSION),
|
||||
domain: env.HY2XS_DOMAIN || "",
|
||||
dnsAaaaPolicy,
|
||||
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
|
||||
@@ -232,6 +250,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
"HY2XS_HYSTERIA_OBFS_PASSWORD",
|
||||
valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)
|
||||
),
|
||||
// Gecko packet sizes не выносятся в env: share URI не умеет их передавать,
|
||||
// поэтому любое нестандартное значение сделало бы hysteria2:// неполным.
|
||||
hysteriaGeckoMinPacketSize: GECKO_DEFAULT_MIN_PACKET_SIZE,
|
||||
hysteriaGeckoMaxPacketSize: GECKO_DEFAULT_MAX_PACKET_SIZE,
|
||||
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
|
||||
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
|
||||
hysteriaIgnoreClientBandwidth: parseBool(
|
||||
@@ -251,6 +273,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
}
|
||||
|
||||
export function validateRuntimeConfig(config: RuntimeConfig): void {
|
||||
if (config.hysteriaObfsType === "gecko") {
|
||||
validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
|
||||
}
|
||||
if (config.ipv6Enabled) {
|
||||
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false");
|
||||
}
|
||||
@@ -297,7 +322,8 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
|
||||
export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
const lines = [
|
||||
"# HY2XS runtime config (editable)",
|
||||
"HY2XS_IPV6_ENABLED=false",
|
||||
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
|
||||
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
|
||||
`HY2XS_DOMAIN=${config.domain}`,
|
||||
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
|
||||
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
|
||||
@@ -320,11 +346,11 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
|
||||
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
|
||||
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
|
||||
"HY2XS_HYSTERIA_AUTH_MODE=http",
|
||||
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
|
||||
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
|
||||
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
|
||||
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
|
||||
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
|
||||
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
|
||||
|
||||
/**
|
||||
* HY2XS production profile: единственное место, где определены значения
|
||||
* серверной политики. Всё остальное (шаблоны, post-install.env, smoke,
|
||||
* build-time compatibility gate) только передаёт эти значения дальше.
|
||||
*/
|
||||
|
||||
export const HY2XS_CONFIG_SCHEMA_VERSION = 2;
|
||||
|
||||
export const HYSTERIA_OBFS_TYPES: readonly HysteriaObfsType[] = ["gecko", "salamander"];
|
||||
|
||||
/** Тип обфускации для новой установки. Salamander остаётся compatibility fallback. */
|
||||
export const DEFAULT_HYSTERIA_OBFS_TYPE: HysteriaObfsType = "gecko";
|
||||
|
||||
/** Upstream defaults Gecko. HY2XS фиксирует их явно как tested production profile. */
|
||||
export const GECKO_DEFAULT_MIN_PACKET_SIZE = 512;
|
||||
export const GECKO_DEFAULT_MAX_PACKET_SIZE = 1200;
|
||||
|
||||
/** Upstream ограничение: maxPacketSize >= minPacketSize и <= 2048. */
|
||||
export const GECKO_MAX_PACKET_SIZE_LIMIT = 2048;
|
||||
|
||||
/**
|
||||
* Fallback congestion controller. Используется, когда Brutal bandwidth
|
||||
* не согласован сторонами; сам Brutal включается через bandwidth up/down.
|
||||
*/
|
||||
export const CONGESTION_TYPE = "bbr";
|
||||
export const BBR_PROFILE = "standard";
|
||||
|
||||
/**
|
||||
* Loss compensation оставлен включённым (upstream default), поэтому
|
||||
* в конфиге явно фиксируется disableLossCompensation: false.
|
||||
*/
|
||||
export const DISABLE_LOSS_COMPENSATION = false;
|
||||
|
||||
/**
|
||||
* QUIC stateless reset нужен HY2XS: клиент со stale-соединением после
|
||||
* перезапуска сервера или сна устройства переподключается сразу.
|
||||
*/
|
||||
export const DISABLE_STATELESS_RESET = false;
|
||||
|
||||
export const QUIC_BASELINE = {
|
||||
initStreamReceiveWindow: 8388608,
|
||||
maxStreamReceiveWindow: 8388608,
|
||||
initConnReceiveWindow: 20971520,
|
||||
maxConnReceiveWindow: 20971520,
|
||||
maxIdleTimeout: "30s",
|
||||
maxIncomingStreams: 1024,
|
||||
disablePathMTUDiscovery: false
|
||||
} as const;
|
||||
|
||||
export function isHysteriaObfsType(value: string): value is HysteriaObfsType {
|
||||
return (HYSTERIA_OBFS_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function normalizeHysteriaObfsType(value: string | undefined): HysteriaObfsType {
|
||||
const obfsType = (value ?? "").trim() || DEFAULT_HYSTERIA_OBFS_TYPE;
|
||||
if (!isHysteriaObfsType(obfsType)) {
|
||||
throw new Error(
|
||||
`invalid HY2XS_HYSTERIA_OBFS_TYPE: ${value} (supported: ${HYSTERIA_OBFS_TYPES.join(", ")})`
|
||||
);
|
||||
}
|
||||
return obfsType;
|
||||
}
|
||||
|
||||
export function validateGeckoPacketSizes(minPacketSize: number, maxPacketSize: number): void {
|
||||
if (!Number.isInteger(minPacketSize) || minPacketSize <= 0) {
|
||||
throw new Error(`invalid Gecko minPacketSize: ${minPacketSize} (must be a positive integer)`);
|
||||
}
|
||||
if (!Number.isInteger(maxPacketSize) || maxPacketSize <= 0) {
|
||||
throw new Error(`invalid Gecko maxPacketSize: ${maxPacketSize} (must be a positive integer)`);
|
||||
}
|
||||
if (maxPacketSize < minPacketSize) {
|
||||
throw new Error(
|
||||
`invalid Gecko packet sizes: maxPacketSize ${maxPacketSize} must be >= minPacketSize ${minPacketSize}`
|
||||
);
|
||||
}
|
||||
if (maxPacketSize > GECKO_MAX_PACKET_SIZE_LIMIT) {
|
||||
throw new Error(
|
||||
`invalid Gecko maxPacketSize: ${maxPacketSize} (upstream limit is ${GECKO_MAX_PACKET_SIZE_LIMIT})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertYamlSafeQuoted(name: string, value: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`missing required ${name}`);
|
||||
}
|
||||
if (/["\n\r]/.test(value)) {
|
||||
throw new Error(`${name} contains forbidden characters for HY2XS YAML profile`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит целиком проверенный obfs-блок. Type selector никогда не собирается
|
||||
* внутри статического YAML, поэтому комбинация вида `type: gecko` + `salamander:`
|
||||
* структурно невозможна.
|
||||
*/
|
||||
export function renderObfsBlock(config: RuntimeConfig): string {
|
||||
const password = assertYamlSafeQuoted("HY2XS_HYSTERIA_OBFS_PASSWORD", config.hysteriaObfsPassword);
|
||||
|
||||
if (config.hysteriaObfsType === "gecko") {
|
||||
validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
|
||||
return [
|
||||
"obfs:",
|
||||
" type: gecko",
|
||||
" gecko:",
|
||||
` password: "${password}"`,
|
||||
` minPacketSize: ${config.hysteriaGeckoMinPacketSize}`,
|
||||
` maxPacketSize: ${config.hysteriaGeckoMaxPacketSize}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (config.hysteriaObfsType === "salamander") {
|
||||
return ["obfs:", " type: salamander", " salamander:", ` password: "${password}"`].join("\n");
|
||||
}
|
||||
|
||||
throw new Error(`unsupported obfs type: ${config.hysteriaObfsType satisfies never}`);
|
||||
}
|
||||
|
||||
export function renderCongestionBlock(): string {
|
||||
return ["congestion:", ` type: ${CONGESTION_TYPE}`, ` bbrProfile: ${BBR_PROFILE}`].join("\n");
|
||||
}
|
||||
|
||||
export function renderQuicBlock(): string {
|
||||
return [
|
||||
"quic:",
|
||||
` initStreamReceiveWindow: ${QUIC_BASELINE.initStreamReceiveWindow}`,
|
||||
` maxStreamReceiveWindow: ${QUIC_BASELINE.maxStreamReceiveWindow}`,
|
||||
` initConnReceiveWindow: ${QUIC_BASELINE.initConnReceiveWindow}`,
|
||||
` maxConnReceiveWindow: ${QUIC_BASELINE.maxConnReceiveWindow}`,
|
||||
` maxIdleTimeout: ${QUIC_BASELINE.maxIdleTimeout}`,
|
||||
` maxIncomingStreams: ${QUIC_BASELINE.maxIncomingStreams}`,
|
||||
` disablePathMTUDiscovery: ${QUIC_BASELINE.disablePathMTUDiscovery}`,
|
||||
` disableStatelessReset: ${DISABLE_STATELESS_RESET}`
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import type { RuntimeContext } from "../types/context";
|
||||
import { readText, renderTemplate, writeText } from "../lib/fs";
|
||||
import { runVisible } from "../lib/process";
|
||||
import {
|
||||
DISABLE_LOSS_COMPENSATION,
|
||||
renderCongestionBlock,
|
||||
renderObfsBlock,
|
||||
renderQuicBlock
|
||||
} from "../config/profile";
|
||||
|
||||
export async function generateConfig(context: RuntimeContext): Promise<void> {
|
||||
export function hysteriaConfigTemplatePath(packageDir: string): string {
|
||||
return `${packageDir}/templates/hysteria/config.yaml.tpl`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Чистый рендер серверного конфига Hysteria2. Используется и на target-сервере,
|
||||
* и build-time compatibility gate, чтобы проверялся ровно тот YAML, который
|
||||
* получит production.
|
||||
*/
|
||||
export function renderHysteriaConfig(context: RuntimeContext, template: string): string {
|
||||
const tlsAcmeBlock = context.config.tlsMode === "acme"
|
||||
? `acme:\n domains:\n - ${context.config.domain}\n email: ${context.config.acmeEmail}\n ca: letsencrypt\n dir: /var/lib/hysteria/acme\n listenHost: 0.0.0.0\n type: ${context.config.acmeType}`
|
||||
: "";
|
||||
@@ -10,22 +25,29 @@ export async function generateConfig(context: RuntimeContext): Promise<void> {
|
||||
? `tls:\n cert: ${context.config.tlsCertPath}\n key: ${context.config.tlsKeyPath}`
|
||||
: "";
|
||||
|
||||
const template = await readText(`${context.options.packageDir}/templates/hysteria/config.yaml.tpl`);
|
||||
const rendered = renderTemplate(template, {
|
||||
return renderTemplate(template, {
|
||||
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
|
||||
HYSTERIA_PORT: context.config.hysteriaPort,
|
||||
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
|
||||
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
|
||||
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
|
||||
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
|
||||
UI_PORT: context.config.uiPort,
|
||||
BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
|
||||
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
|
||||
DISABLE_LOSS_COMPENSATION: DISABLE_LOSS_COMPENSATION ? "true" : "false",
|
||||
IGNORE_CLIENT_BANDWIDTH: context.config.hysteriaIgnoreClientBandwidth ? "true" : "false",
|
||||
OBFS_BLOCK: renderObfsBlock(context.config),
|
||||
CONGESTION_BLOCK: renderCongestionBlock(),
|
||||
QUIC_BLOCK: renderQuicBlock(),
|
||||
TLS_ACME_BLOCK: tlsAcmeBlock,
|
||||
TLS_FILE_BLOCK: tlsFileBlock,
|
||||
AUTH_INSECURE: context.config.tlsMode === "self_signed_dev" ? "true" : "false"
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateConfig(context: RuntimeContext): Promise<void> {
|
||||
const template = await readText(hysteriaConfigTemplatePath(context.options.packageDir));
|
||||
const rendered = renderHysteriaConfig(context, template);
|
||||
|
||||
const configPath = context.config.hysteriaConfigPath;
|
||||
const tmpPath = `${configPath}.tmp`;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { RuntimeConfig } from "../types/context";
|
||||
import {
|
||||
BBR_PROFILE,
|
||||
CONGESTION_TYPE,
|
||||
DISABLE_LOSS_COMPENSATION,
|
||||
DISABLE_STATELESS_RESET,
|
||||
HYSTERIA_OBFS_TYPES,
|
||||
QUIC_BASELINE
|
||||
} from "../config/profile";
|
||||
|
||||
/**
|
||||
* Семантическая проверка сгенерированного серверного конфига.
|
||||
*
|
||||
* Grep по YAML здесь недостаточен: он не отличит `disableStatelessReset: false`
|
||||
* внутри quic от такой же строки где-то ещё и не заметит, что рядом с
|
||||
* `type: gecko` остался блок salamander.
|
||||
*/
|
||||
|
||||
type YamlRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown, path: string): YamlRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`hysteria config: ${path} must be a mapping, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
return value as YamlRecord;
|
||||
}
|
||||
|
||||
function requireSection(root: YamlRecord, key: string): YamlRecord {
|
||||
if (!(key in root)) {
|
||||
throw new Error(`hysteria config: missing required section ${key}`);
|
||||
}
|
||||
return asRecord(root[key], key);
|
||||
}
|
||||
|
||||
function expectValue(section: YamlRecord, path: string, key: string, expected: unknown): void {
|
||||
const actual = section[key];
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`hysteria config: ${path}.${key} must be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function expectNonEmptyString(section: YamlRecord, path: string, key: string): void {
|
||||
const actual = section[key];
|
||||
if (typeof actual !== "string" || actual.trim() === "") {
|
||||
throw new Error(`hysteria config: ${path}.${key} must be a non-empty string, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseHysteriaConfig(raw: string): YamlRecord {
|
||||
const parsed = Bun.YAML.parse(raw);
|
||||
return asRecord(parsed, "<root>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, что установленный конфиг действительно соответствует
|
||||
* HY2XS production profile, а не просто содержит нужные подстроки.
|
||||
*/
|
||||
export function assertHysteriaConfigMatchesProfile(raw: string, config: RuntimeConfig): void {
|
||||
const root = parseHysteriaConfig(raw);
|
||||
|
||||
const listen = root.listen;
|
||||
if (listen !== `${config.hysteriaBindHost}:${config.hysteriaPort}`) {
|
||||
throw new Error(
|
||||
`hysteria config: listen must be ${config.hysteriaBindHost}:${config.hysteriaPort}, got ${JSON.stringify(listen)}`
|
||||
);
|
||||
}
|
||||
|
||||
assertObfsSection(root, config);
|
||||
|
||||
const bandwidth = requireSection(root, "bandwidth");
|
||||
expectValue(bandwidth, "bandwidth", "up", config.hysteriaBandwidthUp);
|
||||
expectValue(bandwidth, "bandwidth", "down", config.hysteriaBandwidthDown);
|
||||
expectValue(bandwidth, "bandwidth", "disableLossCompensation", DISABLE_LOSS_COMPENSATION);
|
||||
|
||||
expectValue(root, "<root>", "ignoreClientBandwidth", config.hysteriaIgnoreClientBandwidth);
|
||||
|
||||
const congestion = requireSection(root, "congestion");
|
||||
expectValue(congestion, "congestion", "type", CONGESTION_TYPE);
|
||||
expectValue(congestion, "congestion", "bbrProfile", BBR_PROFILE);
|
||||
|
||||
const quic = requireSection(root, "quic");
|
||||
expectValue(quic, "quic", "disableStatelessReset", DISABLE_STATELESS_RESET);
|
||||
expectValue(quic, "quic", "initStreamReceiveWindow", QUIC_BASELINE.initStreamReceiveWindow);
|
||||
expectValue(quic, "quic", "maxStreamReceiveWindow", QUIC_BASELINE.maxStreamReceiveWindow);
|
||||
expectValue(quic, "quic", "initConnReceiveWindow", QUIC_BASELINE.initConnReceiveWindow);
|
||||
expectValue(quic, "quic", "maxConnReceiveWindow", QUIC_BASELINE.maxConnReceiveWindow);
|
||||
expectValue(quic, "quic", "maxIncomingStreams", QUIC_BASELINE.maxIncomingStreams);
|
||||
expectValue(quic, "quic", "disablePathMTUDiscovery", QUIC_BASELINE.disablePathMTUDiscovery);
|
||||
|
||||
const trafficStats = requireSection(root, "trafficStats");
|
||||
expectValue(
|
||||
trafficStats,
|
||||
"trafficStats",
|
||||
"listen",
|
||||
`${config.hysteriaTrafficStatsHost}:${config.hysteriaTrafficStatsPort}`
|
||||
);
|
||||
expectNonEmptyString(trafficStats, "trafficStats", "secret");
|
||||
|
||||
const auth = requireSection(root, "auth");
|
||||
expectValue(auth, "auth", "type", "http");
|
||||
const authHttp = asRecord(auth.http, "auth.http");
|
||||
expectNonEmptyString(authHttp, "auth.http", "url");
|
||||
if (!String(authHttp.url).includes("access_token=")) {
|
||||
throw new Error("hysteria config: auth.http.url must carry the HY2XS machine access token");
|
||||
}
|
||||
|
||||
assertTlsSection(root, config);
|
||||
}
|
||||
|
||||
function assertObfsSection(root: YamlRecord, config: RuntimeConfig): void {
|
||||
const obfs = requireSection(root, "obfs");
|
||||
expectValue(obfs, "obfs", "type", config.hysteriaObfsType);
|
||||
|
||||
const present = HYSTERIA_OBFS_TYPES.filter((subtype) => subtype in obfs);
|
||||
if (present.length !== 1 || present[0] !== config.hysteriaObfsType) {
|
||||
throw new Error(
|
||||
`hysteria config: obfs must contain exactly the ${config.hysteriaObfsType} subsection, found [${present.join(", ")}]`
|
||||
);
|
||||
}
|
||||
|
||||
const subtype = asRecord(obfs[config.hysteriaObfsType], `obfs.${config.hysteriaObfsType}`);
|
||||
expectNonEmptyString(subtype, `obfs.${config.hysteriaObfsType}`, "password");
|
||||
|
||||
if (config.hysteriaObfsType === "gecko") {
|
||||
expectValue(subtype, "obfs.gecko", "minPacketSize", config.hysteriaGeckoMinPacketSize);
|
||||
expectValue(subtype, "obfs.gecko", "maxPacketSize", config.hysteriaGeckoMaxPacketSize);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTlsSection(root: YamlRecord, config: RuntimeConfig): void {
|
||||
if (config.tlsMode === "acme") {
|
||||
if ("tls" in root) {
|
||||
throw new Error("hysteria config: acme mode must not emit a tls section");
|
||||
}
|
||||
const acme = requireSection(root, "acme");
|
||||
expectValue(acme, "acme", "type", config.acmeType);
|
||||
if (!Array.isArray(acme.domains) || acme.domains[0] !== config.domain) {
|
||||
throw new Error(`hysteria config: acme.domains must start with ${config.domain}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ("acme" in root) {
|
||||
throw new Error(`hysteria config: ${config.tlsMode} mode must not emit an acme section`);
|
||||
}
|
||||
const tls = requireSection(root, "tls");
|
||||
expectValue(tls, "tls", "cert", config.tlsCertPath);
|
||||
expectValue(tls, "tls", "key", config.tlsKeyPath);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { RuntimeContext } from "../types/context";
|
||||
import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs";
|
||||
import { runVisible } from "../lib/process";
|
||||
import {
|
||||
BBR_PROFILE,
|
||||
CONGESTION_TYPE,
|
||||
DISABLE_LOSS_COMPENSATION,
|
||||
DISABLE_STATELESS_RESET
|
||||
} from "../config/profile";
|
||||
|
||||
export async function writePostInstallEnv(context: RuntimeContext): Promise<void> {
|
||||
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/env/post-install.env.tpl`), {
|
||||
PACKAGE_VERSION: context.packageVersion,
|
||||
PACKAGE_BUILD_ID: context.packageBuildId,
|
||||
CONFIG_SCHEMA_VERSION: context.config.configSchemaVersion,
|
||||
LAST_APPLY_DATE: context.installDate,
|
||||
DOMAIN: context.config.domain,
|
||||
PUBLIC_HOST: context.config.publicHost,
|
||||
@@ -14,16 +21,24 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise<void
|
||||
FIREWALL_MODE: context.config.firewallMode,
|
||||
FIREWALL_STAGED_APPLY: context.config.firewallStagedApply ? "true" : "false",
|
||||
HYSTERIA_VERSION: context.hysteriaVersion,
|
||||
HYSTERIA_RESOLUTION: context.hysteriaResolution,
|
||||
TLS_MODE: context.config.tlsMode,
|
||||
ACME_EMAIL: context.config.acmeEmail,
|
||||
TLS_CERT_PATH: context.config.tlsCertPath,
|
||||
TLS_KEY_PATH: context.config.tlsKeyPath,
|
||||
HYSTERIA_BIND_HOST: context.config.hysteriaBindHost,
|
||||
HYSTERIA_PORT: context.config.hysteriaPort,
|
||||
OBFS_TYPE: context.config.hysteriaObfsType,
|
||||
HYSTERIA_OBFS_PASSWORD: context.config.hysteriaObfsPassword,
|
||||
GECKO_MIN_PACKET_SIZE: context.config.hysteriaGeckoMinPacketSize,
|
||||
GECKO_MAX_PACKET_SIZE: context.config.hysteriaGeckoMaxPacketSize,
|
||||
BANDWIDTH_UP: context.config.hysteriaBandwidthUp,
|
||||
BANDWIDTH_DOWN: context.config.hysteriaBandwidthDown,
|
||||
DISABLE_LOSS_COMPENSATION: DISABLE_LOSS_COMPENSATION ? "true" : "false",
|
||||
IGNORE_CLIENT_BANDWIDTH: context.config.hysteriaIgnoreClientBandwidth ? "true" : "false",
|
||||
CONGESTION_TYPE,
|
||||
BBR_PROFILE,
|
||||
DISABLE_STATELESS_RESET: DISABLE_STATELESS_RESET ? "true" : "false",
|
||||
HYSTERIA_API_HOST: context.config.hysteriaTrafficStatsHost,
|
||||
HYSTERIA_API_PORT: context.config.hysteriaTrafficStatsPort,
|
||||
HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret,
|
||||
|
||||
@@ -1,6 +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 { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
||||
|
||||
function parseLocalAddress(line: string): string {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
@@ -200,18 +202,34 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
||||
|
||||
await runVisible`nft -c -f /etc/nftables.conf`;
|
||||
|
||||
if (context.config.tlsMode === "acme") {
|
||||
await runVisible`grep -q '^acme:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`! grep -q '^tls:' /etc/hysteria/config.yaml`;
|
||||
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
||||
// с production-профилем, а не ищем подстроки.
|
||||
info("verifying effective Hysteria config against HY2XS production profile");
|
||||
assertHysteriaConfigMatchesProfile(await readText("/etc/hysteria/config.yaml"), context.config);
|
||||
|
||||
await assertEffectiveHysteriaVersion(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Установленный бинарник обязан совпадать с версией, замороженной в metadata
|
||||
* пакета. На reconfigure metadata может относиться к другому пакету, поэтому
|
||||
* расхождение там — предупреждение, а не отказ.
|
||||
*/
|
||||
async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise<void> {
|
||||
const packagedVersion = context.hysteriaVersion.trim();
|
||||
if (!packagedVersion || packagedVersion === "unknown") {
|
||||
return;
|
||||
}
|
||||
if (context.config.tlsMode === "file") {
|
||||
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`grep -q 'insecure: false' /etc/hysteria/config.yaml`;
|
||||
}
|
||||
if (context.config.tlsMode === "self_signed_dev") {
|
||||
await runVisible`grep -q '^tls:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`! grep -q '^acme:' /etc/hysteria/config.yaml`;
|
||||
await runVisible`grep -q 'insecure: true' /etc/hysteria/config.yaml`;
|
||||
|
||||
const raw = await runSecret`/usr/local/bin/hysteria version`;
|
||||
const match = raw.match(/v\d+\.\d+\.\d+/);
|
||||
const effective = match ? match[0] : raw.trim();
|
||||
|
||||
if (effective !== packagedVersion) {
|
||||
const message = `effective Hysteria version ${effective} does not match package metadata ${packagedVersion}`;
|
||||
if (context.mode === "install") {
|
||||
throw new Error(message);
|
||||
}
|
||||
info(`warning: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ export type TlsMode = "acme" | "file" | "self_signed_dev";
|
||||
|
||||
export type DnsAaaaPolicy = "strict" | "warn" | "off";
|
||||
|
||||
export type HysteriaObfsType = "gecko" | "salamander";
|
||||
|
||||
export type RuntimeConfig = {
|
||||
configSchemaVersion: number;
|
||||
domain: string;
|
||||
dnsAaaaPolicy: DnsAaaaPolicy;
|
||||
publicHost: string;
|
||||
@@ -52,8 +55,10 @@ export type RuntimeConfig = {
|
||||
hysteriaTrafficStatsHost: string;
|
||||
hysteriaTrafficStatsPort: number;
|
||||
hysteriaTrafficStatsSecret: string;
|
||||
hysteriaObfsType: "salamander";
|
||||
hysteriaObfsType: HysteriaObfsType;
|
||||
hysteriaObfsPassword: string;
|
||||
hysteriaGeckoMinPacketSize: number;
|
||||
hysteriaGeckoMaxPacketSize: number;
|
||||
hysteriaBandwidthUp: string;
|
||||
hysteriaBandwidthDown: string;
|
||||
hysteriaIgnoreClientBandwidth: boolean;
|
||||
@@ -72,6 +77,8 @@ export type RuntimeContext = {
|
||||
packageBuildId: string;
|
||||
installDate: string;
|
||||
hysteriaVersion: string;
|
||||
/** Как версия Hysteria была выбрана на этапе сборки пакета: latest-stable | pinned | override. */
|
||||
hysteriaResolution: string;
|
||||
};
|
||||
|
||||
export type InstallContext = RuntimeContext & {
|
||||
|
||||
Reference in New Issue
Block a user