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:
2026-08-27 08:15:02 +05:00
parent 0205334cd8
commit ddf0ddf71e
53 changed files with 4827 additions and 291 deletions
+179
View File
@@ -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");
}
+2 -1
View File
@@ -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");
+1
View File
@@ -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", "")
+2 -1
View File
@@ -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");
+35 -9
View File
@@ -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}`,
+138
View File
@@ -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");
}
+26 -4
View File
@@ -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`;
+151
View File
@@ -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);
}
+15
View File
@@ -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,
+30 -12
View File
@@ -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}`);
}
}
+8 -1
View File
@@ -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 & {