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
+1
View File
@@ -7,6 +7,7 @@
"packageManager": "bun@1.3.13",
"scripts": {
"check": "tsc --noEmit",
"test": "bun test",
"build": "bun build src/cli.ts --compile --target=bun-linux-x64 --outfile dist/hy2xs-orchestrator"
},
"devDependencies": {
+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 & {
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderHysteriaConfig } from "../src/steps/config";
import { assertHysteriaConfigMatchesProfile } from "../src/steps/configAssertions";
import { baselineConfig, testContext } from "./fixtures";
const TEMPLATE = readFileSync(
join(import.meta.dir, "..", "..", "package", "templates", "hysteria", "config.yaml.tpl"),
"utf8"
);
function renderFor(overrides: Record<string, string | null> = {}) {
const config = baselineConfig(overrides);
return { config, yaml: renderHysteriaConfig(testContext(config), TEMPLATE) };
}
describe("сгенерированный конфиг проходит собственную семантическую проверку", () => {
for (const obfsType of ["gecko", "salamander"]) {
for (const tlsMode of ["acme", "file"]) {
test(`obfs=${obfsType}, tls=${tlsMode}`, () => {
const { config, yaml } = renderFor({
HY2XS_HYSTERIA_OBFS_TYPE: obfsType,
HY2XS_TLS_MODE: tlsMode
});
expect(() => assertHysteriaConfigMatchesProfile(yaml, config)).not.toThrow();
});
}
}
});
describe("подмены в конфиге обнаруживаются", () => {
test("тип obfs не совпадает с профилем", () => {
const { config, yaml } = renderFor({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
const tampered = yaml.replace("type: gecko", "type: salamander");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/obfs.*type/);
});
test("в obfs остался второй подтип", () => {
const { config, yaml } = renderFor({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
const tampered = yaml.replace(
"obfs:\n type: gecko",
"obfs:\n salamander:\n password: \"leftover\"\n type: gecko"
);
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/exactly the gecko subsection/);
});
test("изменённый gecko packet size", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("maxPacketSize: 1200", "maxPacketSize: 1400");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/maxPacketSize/);
});
test("stateless reset выключен", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("disableStatelessReset: false", "disableStatelessReset: true");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/disableStatelessReset/);
});
test("loss compensation выключена", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("disableLossCompensation: false", "disableLossCompensation: true");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/disableLossCompensation/);
});
test("подменён congestion controller", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("type: bbr", "type: reno");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/congestion\.type/);
});
test("подменён bbr profile", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("bbrProfile: standard", "bbrProfile: aggressive");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/bbrProfile/);
});
test("исчезла секция congestion", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace(/congestion:\n {2}type: bbr\n {2}bbrProfile: standard\n/, "");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/missing required section congestion/);
});
test("auth url потерял machine token", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace(/\?access_token=[^\s]*/, "");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/machine access token/);
});
test("пустой obfs-пароль", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace(/password: "[^"]*"/, 'password: ""');
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/non-empty string/);
});
test("acme-профиль с посторонней tls-секцией", () => {
const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "acme" });
const tampered = `${yaml}\ntls:\n cert: /tmp/x.crt\n key: /tmp/x.key\n`;
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/must not emit a tls section/);
});
test("file-профиль с посторонней acme-секцией", () => {
const { config, yaml } = renderFor({ HY2XS_TLS_MODE: "file" });
const tampered = `${yaml}\nacme:\n domains:\n - x.example.com\n`;
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/must not emit an acme section/);
});
test("подменён listen", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("listen: 0.0.0.0:443", "listen: 0.0.0.0:8443");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/listen must be/);
});
test("подменён trafficStats listen", () => {
const { config, yaml } = renderFor();
const tampered = yaml.replace("127.0.0.1:36712", "0.0.0.0:36712");
expect(() => assertHysteriaConfigMatchesProfile(tampered, config)).toThrow(/trafficStats\.listen/);
});
test("невалидный YAML отвергается", () => {
const { config } = renderFor();
expect(() => assertHysteriaConfigMatchesProfile("just a string", config)).toThrow();
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, test } from "bun:test";
import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env";
import {
GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE,
HY2XS_CONFIG_SCHEMA_VERSION,
normalizeHysteriaObfsType,
validateGeckoPacketSizes
} from "../src/config/profile";
import { baselineConfig, envText } from "./fixtures";
describe("obfs type resolution", () => {
test("новая установка без явного значения выбирает gecko", () => {
const config = parseRuntimeEnv(envText({ HY2XS_HYSTERIA_OBFS_TYPE: null }));
expect(config.hysteriaObfsType).toBe("gecko");
});
test("явный gecko принимается", () => {
expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" }).hysteriaObfsType).toBe("gecko");
});
test("явный salamander принимается как compatibility fallback", () => {
expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" }).hysteriaObfsType).toBe("salamander");
});
test("неизвестный тип отклоняется", () => {
expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "none" })).toThrow(
/invalid HY2XS_HYSTERIA_OBFS_TYPE/
);
});
test("тип нечувствителен к окружающим пробелам, но не к регистру", () => {
expect(normalizeHysteriaObfsType(" gecko ")).toBe("gecko");
expect(() => normalizeHysteriaObfsType("Gecko")).toThrow(/invalid HY2XS_HYSTERIA_OBFS_TYPE/);
});
test("пустой obfs-пароль означает автогенерацию, а не пустое значение в конфиге", () => {
for (const raw of ["", "__GENERATE__"]) {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: raw });
expect(config.hysteriaObfsPassword).not.toBe("");
expect(config.hysteriaObfsPassword).not.toBe("__GENERATE__");
expect(config.hysteriaObfsPassword.length).toBeGreaterThanOrEqual(24);
}
});
test("два разбора генерируют разные пароли", () => {
const first = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
const second = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
expect(first.hysteriaObfsPassword).not.toBe(second.hysteriaObfsPassword);
});
test("obfs-пароль с кавычкой или переводом строки отклоняется", () => {
expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: 'pa"ss' })).toThrow(
/forbidden characters/
);
});
});
describe("gecko packet sizes", () => {
test("baseline фиксирует upstream defaults 512/1200", () => {
const config = baselineConfig();
expect(config.hysteriaGeckoMinPacketSize).toBe(512);
expect(config.hysteriaGeckoMaxPacketSize).toBe(1200);
expect(config.hysteriaGeckoMinPacketSize).toBe(GECKO_DEFAULT_MIN_PACKET_SIZE);
expect(config.hysteriaGeckoMaxPacketSize).toBe(GECKO_DEFAULT_MAX_PACKET_SIZE);
});
test("валидная пара принимается", () => {
expect(() => validateGeckoPacketSizes(512, 1200)).not.toThrow();
expect(() => validateGeckoPacketSizes(700, 700)).not.toThrow();
});
test("max < min отклоняется", () => {
expect(() => validateGeckoPacketSizes(1200, 512)).toThrow(/must be >= minPacketSize/);
});
test("max > 2048 отклоняется", () => {
expect(() => validateGeckoPacketSizes(512, 2049)).toThrow(/upstream limit is 2048/);
});
test("max == 2048 принимается как граничное значение", () => {
expect(() => validateGeckoPacketSizes(512, 2048)).not.toThrow();
});
test("неположительный min отклоняется", () => {
expect(() => validateGeckoPacketSizes(0, 1200)).toThrow(/positive integer/);
expect(() => validateGeckoPacketSizes(-1, 1200)).toThrow(/positive integer/);
});
test("нецелые значения отклоняются", () => {
expect(() => validateGeckoPacketSizes(512.5, 1200)).toThrow(/positive integer/);
});
});
describe("config schema version", () => {
test("отсутствие значения даёт текущую схему", () => {
const config = parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }));
expect(config.configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION);
});
test("схема v0/v1 отклоняется с указанием на чистую установку", () => {
expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "1" })).toThrow(
/requires a clean installation/
);
});
test("нечисловая схема отклоняется", () => {
expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "two" })).toThrow(
/invalid HY2XS_CONFIG_SCHEMA_VERSION/
);
});
});
describe("renderRuntimeEnv round-trip", () => {
// Регрессия: renderRuntimeEnv раньше печатал литерал `salamander`, из-за чего
// записанный на диск runtime-конфиг расходился с разобранным.
test("gecko переживает render → parse без потерь", () => {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
expect(reparsed).toEqual(config);
});
test("salamander переживает render → parse без подмены на дефолт", () => {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
const rendered = renderRuntimeEnv(config);
expect(rendered).toContain("HY2XS_HYSTERIA_OBFS_TYPE=salamander");
expect(rendered).not.toContain("HY2XS_HYSTERIA_OBFS_TYPE=gecko");
expect(parseRuntimeEnv(rendered)).toEqual(config);
});
test("render не теряет ни одно поле runtime-конфига", () => {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
for (const key of Object.keys(config) as (keyof typeof config)[]) {
expect(reparsed[key]).toEqual(config[key]);
}
});
test("схема конфигурации попадает в runtime-файл", () => {
expect(renderRuntimeEnv(baselineConfig())).toContain(
`HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}`
);
});
});
describe("остальные production-инварианты", () => {
test("IPv6 остаётся запрещённым", () => {
expect(() => baselineConfig({ HY2XS_IPV6_ENABLED: "true" })).toThrow(/IPv4-only/);
});
test("auth mode зафиксирован в http", () => {
expect(() => baselineConfig({ HY2XS_HYSTERIA_AUTH_MODE: "password" })).toThrow(
/HY2XS_HYSTERIA_AUTH_MODE is fixed/
);
});
test("ACME DNS challenge пока запрещён в production-профиле", () => {
expect(() => baselineConfig({ HY2XS_ACME_TYPE: "dns" })).toThrow(/not supported in production profile/);
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { RuntimeConfig, RuntimeContext } from "../src/types/context";
import { parseRuntimeEnv } from "../src/config/env";
/**
* Минимальный валидный packaged baseline. Тесты меняют только те строки,
* которые проверяют, поэтому «валидный по умолчанию» набор живёт в одном месте.
*/
export const BASELINE_ENV_LINES: readonly string[] = [
"HY2XS_CONFIG_SCHEMA_VERSION=2",
"HY2XS_IPV6_ENABLED=false",
"HY2XS_DOMAIN=vpn.example.com",
"HY2XS_DNS_AAAA_POLICY=strict",
"HY2XS_PUBLIC_HOST=vpn.example.com",
"HY2XS_PUBLIC_PORT=443",
"HY2XS_SSH_PORT=2323",
"HY2XS_FIREWALL_MODE=takeover",
"HY2XS_FIREWALL_STAGED_APPLY=true",
"HY2XS_UI_BIND_HOST=127.0.0.1",
"HY2XS_UI_PUBLIC_ACCESS=false",
"HY2XS_UI_PORT=8080",
"HY2XS_ADMIN_USER=hy2xsadmin",
"HY2XS_ADMIN_INITIAL_PASSWORD=initial-password",
"HY2XS_ADMIN_CON_PASS=connection-password",
"HY2XS_FORCE_PASSWORD_CHANGE=false",
"HY2XS_ALLOW_SELF_SIGNED_DEV=false",
"HY2XS_TLS_MODE=acme",
"HY2XS_ACME_TYPE=http",
"HY2XS_ACME_EMAIL=admin@example.com",
"HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt",
"HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key",
"HY2XS_HYSTERIA_BIND_HOST=0.0.0.0",
"HY2XS_HYSTERIA_PORT=443",
"HY2XS_HYSTERIA_AUTH_MODE=http",
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1",
"HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712",
"HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=traffic-stats-secret",
"HY2XS_HYSTERIA_OBFS_TYPE=gecko",
"HY2XS_HYSTERIA_OBFS_PASSWORD=obfs-password",
"HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps",
"HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps",
"HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false",
"HY2XS_HYSTERIA_CONFIG_PATH=/etc/hysteria/config.yaml",
"HY2XS_INSTALL_DIR=/opt/hy2xs-admin",
"HY2XS_DATA_DIR=/var/lib/hy2xs-admin",
"HY2XS_LOG_DIR=/var/log/hy2xs"
];
/**
* Собирает env-текст из baseline с переопределениями.
* Значение `null` удаляет ключ целиком (проверка поведения по умолчанию).
*/
export function envText(overrides: Record<string, string | null> = {}): string {
const lines: string[] = [];
const applied = new Set<string>();
for (const line of BASELINE_ENV_LINES) {
const key = line.slice(0, line.indexOf("="));
if (key in overrides) {
applied.add(key);
const value = overrides[key];
if (value === null) {
continue;
}
lines.push(`${key}=${value}`);
continue;
}
lines.push(line);
}
for (const [key, value] of Object.entries(overrides)) {
if (!applied.has(key) && value !== null) {
lines.push(`${key}=${value}`);
}
}
return `${lines.join("\n")}\n`;
}
export function baselineConfig(overrides: Record<string, string | null> = {}): RuntimeConfig {
return parseRuntimeEnv(envText(overrides));
}
export function testContext(config: RuntimeConfig): RuntimeContext {
return {
mode: "install",
options: {
packageDir: "/opt/hy2xs/package",
sourceConfigPath: "",
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: true,
skipFirewall: false,
skipServiceStart: false,
skipSmoke: false
},
config,
packageVersion: "1.0.0",
packageBuildId: "test-build",
installDate: "2026-08-27T00:00:00.000Z",
hysteriaVersion: "v2.12.2",
hysteriaResolution: "latest-stable"
};
}
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, test } from "bun:test";
import {
HYSTERIA_LINUX_AMD64_ASSET,
compareVersionParts,
parseAppTag,
parseVersion,
renderLockEnv,
selectLatestStableRelease,
selectLinuxAmd64Asset,
selectReleaseByVersion,
type GithubRelease
} from "../src/build/hysteriaRelease";
/** Upstream отдаёт URL с неэкранированным слэшем в теге; берём его как есть. */
function assetUrl(tag: string): string {
return `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`;
}
function release(tag: string, overrides: Partial<GithubRelease> = {}): GithubRelease {
return {
tag_name: tag,
draft: false,
prerelease: false,
published_at: "2026-08-23T00:59:00Z",
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl(tag) }],
...overrides
};
}
/** Реалистичная выборка: stable, prerelease, draft, чужие семейства тегов. */
const UPSTREAM_FIXTURE: GithubRelease[] = [
release("app/v2.13.0-rc1", { prerelease: true }),
release("app/v2.13.0", { prerelease: true }),
release("app/v2.14.0", { draft: true }),
release("app/v2.12.2"),
release("app/v2.12.1"),
release("app/v2.9.10"),
release("app/v2.9.2"),
release("core/v2.12.2"),
release("docs/v1.0.0"),
release("v2.12.2")
];
describe("selectLatestStableRelease", () => {
test("выбирает последнюю стабильную app-версию", () => {
const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
expect(resolved.version).toBe("v2.12.2");
expect(resolved.tag).toBe("app/v2.12.2");
});
test("игнорирует prerelease", () => {
const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { prerelease: true })]);
expect(resolved.version).toBe("v2.12.2");
});
test("игнорирует draft", () => {
const resolved = selectLatestStableRelease([release("app/v2.12.2"), release("app/v2.13.0", { draft: true })]);
expect(resolved.version).toBe("v2.12.2");
});
test("игнорирует чужие семейства тегов", () => {
const resolved = selectLatestStableRelease([release("core/v9.9.9"), release("app/v2.12.2")]);
expect(resolved.version).toBe("v2.12.2");
});
test("игнорирует тег без префикса app/", () => {
expect(() => selectLatestStableRelease([release("v2.12.2")])).toThrow(/no stable/);
});
test("сравнение числовое, а не лексикографическое", () => {
const resolved = selectLatestStableRelease([release("app/v2.9.2"), release("app/v2.9.10")]);
expect(resolved.version).toBe("v2.9.10");
});
test("порядок элементов в ответе API не влияет на результат", () => {
const reversed = [...UPSTREAM_FIXTURE].reverse();
expect(selectLatestStableRelease(reversed).version).toBe("v2.12.2");
});
test("невалидный semver в теге отбрасывается", () => {
expect(() => selectLatestStableRelease([release("app/v2.12"), release("app/vNEXT")])).toThrow(
/no stable/
);
});
test("пустой список релизов даёт понятную ошибку", () => {
expect(() => selectLatestStableRelease([])).toThrow(/no stable/);
});
test("берёт browser_download_url ровно как отдал API, без пересборки строки", () => {
const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
expect(resolved.artifactUrl).toBe(
"https://github.com/HyNetworks/hysteria/releases/download/app/v2.12.2/hysteria-linux-amd64"
);
});
test("процентное кодирование в url upstream не переписывается", () => {
const encoded = "https://github.com/HyNetworks/hysteria/releases/download/app%2Fv2.12.2/hysteria-linux-amd64";
const resolved = selectLatestStableRelease([
release("app/v2.12.2", {
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: encoded }]
})
]);
expect(resolved.artifactUrl).toBe(encoded);
});
});
describe("selectLinuxAmd64Asset", () => {
test("отсутствующий linux-amd64 asset — ошибка", () => {
const broken = release("app/v2.12.2", {
assets: [{ name: "hysteria-linux-arm64", browser_download_url: assetUrl("app/v2.12.2") }]
});
expect(() => selectLatestStableRelease([broken])).toThrow(/has no hysteria-linux-amd64 asset/);
});
test("дублирующийся asset — ошибка, а не случайный выбор", () => {
const ambiguous = release("app/v2.12.2", {
assets: [
{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: assetUrl("app/v2.12.2") },
{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "https://example.com/evil" }
]
});
expect(() => selectLatestStableRelease([ambiguous])).toThrow(/ambiguous/);
});
test("non-https url отклоняется", () => {
const insecure = release("app/v2.12.2", {
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "http://github.com/x" }]
});
expect(() => selectLinuxAmd64Asset(insecure)).toThrow(/not https/);
});
test("пустой url отклоняется", () => {
const empty = release("app/v2.12.2", {
assets: [{ name: HYSTERIA_LINUX_AMD64_ASSET, browser_download_url: "" }]
});
expect(() => selectLinuxAmd64Asset(empty)).toThrow(/empty asset download url/);
});
test("частичное совпадение имени asset не принимается", () => {
const partial = release("app/v2.12.2", {
assets: [
{ name: "hysteria-linux-amd64-avx", browser_download_url: assetUrl("app/v2.12.2") }
]
});
expect(() => selectLinuxAmd64Asset(partial)).toThrow(/has no hysteria-linux-amd64 asset/);
});
});
describe("selectReleaseByVersion", () => {
test("находит явно запрошенную версию, даже если она не последняя", () => {
const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.12.1");
expect(resolved.version).toBe("v2.12.1");
expect(resolved.tag).toBe("app/v2.12.1");
});
test("позволяет осознанно закрепить prerelease по явному запросу", () => {
const resolved = selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.13.0");
expect(resolved.tag).toBe("app/v2.13.0");
// при этом автоматический выбор её по-прежнему не берёт
expect(selectLatestStableRelease(UPSTREAM_FIXTURE).version).toBe("v2.12.2");
});
test("draft по явному запросу отклоняется", () => {
expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v2.14.0")).toThrow(/draft release/);
});
test("несуществующая версия даёт ошибку", () => {
expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, "v9.9.9")).toThrow(/was not found/);
});
test("невалидный формат override отклоняется", () => {
for (const bad of ["2.12.2", "v2.12", "latest", ""]) {
expect(() => selectReleaseByVersion(UPSTREAM_FIXTURE, bad)).toThrow(/invalid Hysteria version override/);
}
});
});
describe("парсинг версий", () => {
test("parseAppTag", () => {
expect(parseAppTag("app/v2.12.2")?.version).toBe("v2.12.2");
expect(parseAppTag(" app/v2.12.2 ")?.version).toBe("v2.12.2");
expect(parseAppTag("app/v2.12.2-rc1")).toBeNull();
expect(parseAppTag("app/2.12.2")).toBeNull();
});
test("parseVersion", () => {
expect(parseVersion("v2.12.2")).toEqual([2, 12, 2]);
expect(parseVersion("v2.12.2-rc1")).toBeNull();
});
test("compareVersionParts", () => {
expect(compareVersionParts([2, 12, 2], [2, 12, 1])).toBe(1);
expect(compareVersionParts([2, 9, 10], [2, 9, 2])).toBe(1);
expect(compareVersionParts([2, 12, 2], [2, 12, 2])).toBe(0);
expect(compareVersionParts([1, 99, 99], [2, 0, 0])).toBe(-1);
});
});
describe("renderLockEnv", () => {
const resolved = selectLatestStableRelease(UPSTREAM_FIXTURE);
const sha = "a".repeat(64);
test("содержит замороженные версию, url и sha256", () => {
const rendered = renderLockEnv(resolved, sha, "latest-stable", "2026-08-27T00:00:00Z");
expect(rendered).toContain("HYSTERIA_VERSION=v2.12.2");
expect(rendered).toContain(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
expect(rendered).toContain(`HYSTERIA_ARTIFACT_SHA256=${sha}`);
expect(rendered).toContain("HYSTERIA_RESOLUTION=latest-stable");
expect(rendered).toContain("HYSTERIA_RESOLVED_AT=2026-08-27T00:00:00Z");
});
test("невалидный sha256 отклоняется", () => {
for (const bad of ["", "abc", "replace-with-real-sha256", "A".repeat(64), "z".repeat(64)]) {
expect(() => renderLockEnv(resolved, bad, "latest-stable", "2026-08-27T00:00:00Z")).toThrow(
/invalid Hysteria artifact sha256/
);
}
});
});
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
HYSTERIA_LINUX_AMD64_ASSET,
selectLatestStableRelease,
type GithubRelease
} from "../src/build/hysteriaRelease";
function release(tag: string): GithubRelease {
return {
tag_name: tag,
draft: false,
prerelease: false,
published_at: "2026-08-23T00:59:00Z",
assets: [
{
name: HYSTERIA_LINUX_AMD64_ASSET,
browser_download_url: `https://github.com/HyNetworks/hysteria/releases/download/${tag}/${HYSTERIA_LINUX_AMD64_ASSET}`
}
]
};
}
function srcFile(...parts: string[]): string {
return readFileSync(join(import.meta.dir, "..", "src", ...parts), "utf8");
}
describe("release rollover", () => {
// Пакет A собран сегодня, пакет B — завтра. Выход новой upstream-версии
// не должен менять содержимое уже собранного пакета A.
const upstreamToday = [release("app/v2.12.1"), release("app/v2.12.2")];
const upstreamTomorrow = [...upstreamToday, release("app/v2.12.3")];
test("сборка сегодня закрепляет 2.12.2", () => {
expect(selectLatestStableRelease(upstreamToday).version).toBe("v2.12.2");
});
test("сборка завтра закрепляет 2.12.3", () => {
expect(selectLatestStableRelease(upstreamTomorrow).version).toBe("v2.12.3");
});
test("резолв чистый: тот же список всегда даёт тот же результат", () => {
const first = selectLatestStableRelease(upstreamToday);
const second = selectLatestStableRelease(upstreamToday);
expect(first).toEqual(second);
});
});
describe("install-time никогда не резолвит latest", () => {
// Гарантия acceptance-критерия: "latest" относится к моменту сборки,
// поэтому переустановка старого пакета обязана ставить старую версию.
const installTimeSources = [
["commands", "install.ts"],
["steps", "hysteria.ts"],
["lib", "packageMeta.ts"],
["commands", "reconfigure.ts"]
];
test("install-time код не импортирует build-time резолвер", () => {
for (const parts of installTimeSources) {
expect(srcFile(...parts)).not.toContain("build/hysteriaRelease");
}
});
test("install-time код не обращается к upstream API и не использует moving latest", () => {
for (const parts of installTimeSources) {
const source = srcFile(...parts);
expect(source).not.toContain("api.github.com");
expect(source).not.toContain("download.hysteria.network");
expect(source).not.toMatch(/releases\/latest/);
}
});
test("установка берёт версию, url и sha256 только из metadata пакета", () => {
const install = srcFile("commands", "install.ts");
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.version"');
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.url"');
expect(install).toContain('readPackageValue(options.packageDir, "hysteria.sha256"');
});
test("отсутствие любой части lock-метаданных прерывает установку", () => {
expect(srcFile("commands", "install.ts")).toContain("missing Hysteria lock metadata in package");
});
test("установленный бинарник сверяется с закреплённой версией", () => {
const step = srcFile("steps", "hysteria.ts");
expect(step).toContain("installed Hysteria version mismatch");
expect(step).toContain("sha256sum -c -");
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderHysteriaConfig } from "../src/steps/config";
import { renderObfsBlock } from "../src/config/profile";
import { baselineConfig, testContext } from "./fixtures";
const TEMPLATE = readFileSync(
join(import.meta.dir, "..", "..", "package", "templates", "hysteria", "config.yaml.tpl"),
"utf8"
);
function render(overrides: Record<string, string | null> = {}): string {
return renderHysteriaConfig(testContext(baselineConfig(overrides)), TEMPLATE);
}
describe("obfs block", () => {
test("gecko рендерит только gecko-подблок", () => {
const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
expect(yaml).toContain("obfs:\n type: gecko\n gecko:");
expect(yaml).toContain("minPacketSize: 512");
expect(yaml).toContain("maxPacketSize: 1200");
expect(yaml).not.toContain("salamander");
});
test("salamander рендерит только salamander-подблок", () => {
const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
expect(yaml).toContain("obfs:\n type: salamander\n salamander:");
expect(yaml).not.toContain("gecko");
expect(yaml).not.toContain("minPacketSize");
});
test("в конфиге никогда нет двух подтипов obfs одновременно", () => {
for (const obfsType of ["gecko", "salamander"]) {
const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: obfsType });
const subtypes = [" gecko:", " salamander:"].filter((marker) => yaml.includes(marker));
expect(subtypes).toHaveLength(1);
}
});
test("obfs-пароль экранируется кавычками и попадает в оба профиля", () => {
for (const obfsType of ["gecko", "salamander"]) {
const yaml = render({
HY2XS_HYSTERIA_OBFS_TYPE: obfsType,
HY2XS_HYSTERIA_OBFS_PASSWORD: "p@ss:w#rd with spaces"
});
expect(yaml).toContain('password: "p@ss:w#rd with spaces"');
}
});
test("рендер блока защищается от YAML-инъекции даже в обход env-валидации", () => {
const config = baselineConfig();
const injected = { ...config, hysteriaObfsPassword: 'x"\nlisten: 0.0.0.0:1' };
expect(() => renderObfsBlock(injected)).toThrow(/forbidden characters/);
});
test("рендер отклоняет невалидные gecko-размеры, даже если они пришли из обхода env", () => {
const config = baselineConfig();
expect(() => renderObfsBlock({ ...config, hysteriaGeckoMaxPacketSize: 4096 })).toThrow(
/upstream limit is 2048/
);
});
});
describe("современный серверный baseline", () => {
test("bandwidth содержит явный disableLossCompensation: false", () => {
expect(render()).toContain("disableLossCompensation: false");
});
test("congestion фиксирует bbr/standard", () => {
const yaml = render();
expect(yaml).toContain("congestion:\n type: bbr\n bbrProfile: standard");
});
test("quic фиксирует disableStatelessReset: false", () => {
expect(render()).toContain("disableStatelessReset: false");
});
test("quic содержит полный набор baseline-полей", () => {
const yaml = render();
for (const field of [
"initStreamReceiveWindow: 8388608",
"maxStreamReceiveWindow: 8388608",
"initConnReceiveWindow: 20971520",
"maxConnReceiveWindow: 20971520",
"maxIdleTimeout: 30s",
"maxIncomingStreams: 1024",
"disablePathMTUDiscovery: false"
]) {
expect(yaml).toContain(field);
}
});
test("bandwidth-политика 50/50 сохраняется", () => {
const yaml = render();
expect(yaml).toContain('up: "50 mbps"');
expect(yaml).toContain('down: "50 mbps"');
expect(yaml).toContain("ignoreClientBandwidth: false");
});
});
describe("TLS-режимы", () => {
test("acme рендерит только acme-блок", () => {
const yaml = render({ HY2XS_TLS_MODE: "acme" });
expect(yaml).toMatch(/^acme:/m);
expect(yaml).not.toMatch(/^tls:/m);
expect(yaml).toContain("insecure: false");
});
test("file рендерит только tls-блок", () => {
const yaml = render({ HY2XS_TLS_MODE: "file" });
expect(yaml).toMatch(/^tls:/m);
expect(yaml).not.toMatch(/^acme:/m);
});
test("self_signed_dev включает insecure для локального auth", () => {
const yaml = render({
HY2XS_TLS_MODE: "self_signed_dev",
HY2XS_ALLOW_SELF_SIGNED_DEV: "true"
});
expect(yaml).toContain("insecure: true");
});
});
describe("шаблон", () => {
test("не осталось незаполненных плейсхолдеров", () => {
expect(render()).not.toMatch(/\{\{\s*[A-Z0-9_]+\s*\}\}/);
});
test("auth URL содержит machine access token", () => {
expect(render()).toContain(
"/hui/hysteria2/auth?access_token=traffic-stats-secret"
);
});
test("шаблон не содержит захардкоженного типа обфускации", () => {
expect(TEMPLATE).not.toContain("salamander");
expect(TEMPLATE).not.toContain("gecko");
expect(TEMPLATE).toContain("{{OBFS_BLOCK}}");
});
});
@@ -0,0 +1,171 @@
/**
* Build-time CLI: рендерит канонический серверный конфиг HY2XS тем же кодом,
* который работает на target-сервере.
*
* Используется compatibility gate: получившийся YAML скармливается реальному
* upstream-бинарнику Hysteria до того, как будет создан release package.
*
* bun run tools/render-canonical-config.ts \
* --package-dir . --obfs gecko --tls-mode file \
* --cert /tmp/x.crt --key /tmp/x.key --out /tmp/config.yaml
*/
import { parseRuntimeEnv } from "../src/config/env";
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
import type { RuntimeContext } from "../src/types/context";
type Options = {
packageDir: string;
configPath: string;
templatePath: string;
obfs: string;
tlsMode: string;
certPath: string;
keyPath: string;
hysteriaPort: string;
trafficStatsPort: string;
outPath: string;
};
function fail(message: string): never {
console.error(`[hy2xs-build] ERROR: ${message}`);
process.exit(1);
}
function parseArgs(argv: string[]): Options {
const options: Options = {
packageDir: "",
configPath: "",
templatePath: "",
obfs: "gecko",
tlsMode: "file",
certPath: "",
keyPath: "",
hysteriaPort: "",
trafficStatsPort: "",
outPath: ""
};
const flags: Record<string, keyof Options> = {
"--package-dir": "packageDir",
"--config": "configPath",
"--template": "templatePath",
"--obfs": "obfs",
"--tls-mode": "tlsMode",
"--cert": "certPath",
"--key": "keyPath",
"--port": "hysteriaPort",
"--traffic-stats-port": "trafficStatsPort",
"--out": "outPath"
};
for (let i = 0; i < argv.length; i += 1) {
const key = flags[argv[i]];
if (!key) {
fail(`unknown argument: ${argv[i]}`);
}
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) {
fail(`missing value for ${argv[i]}`);
}
options[key] = value;
i += 1;
}
if (!options.packageDir) {
fail("missing --package-dir");
}
if (!options.outPath) {
fail("missing --out");
}
if (!(HYSTERIA_OBFS_TYPES as readonly string[]).includes(options.obfs)) {
fail(`unsupported --obfs value: ${options.obfs}`);
}
options.configPath ||= `${options.packageDir}/config/hy2xs.env`;
options.templatePath ||= hysteriaConfigTemplatePath(options.packageDir);
return options;
}
function overrideEnv(source: string, overrides: Record<string, string>): string {
const pending = new Map(Object.entries(overrides));
const lines = source.split(/\r?\n/).map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
return line;
}
const key = trimmed.slice(0, trimmed.indexOf("="));
if (pending.has(key)) {
const value = pending.get(key) as string;
pending.delete(key);
return `${key}=${value}`;
}
return line;
});
for (const [key, value] of pending) {
lines.push(`${key}=${value}`);
}
return lines.join("\n");
}
async function main(): Promise<void> {
const options = parseArgs(Bun.argv.slice(2));
const overrides: Record<string, string> = {
HY2XS_HYSTERIA_OBFS_TYPE: options.obfs,
HY2XS_TLS_MODE: options.tlsMode,
// Compatibility gate работает офлайн и не должен зависеть от секретов пакета.
HY2XS_HYSTERIA_OBFS_PASSWORD: "hy2xs-compat-gate-obfs-password",
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET: "hy2xs-compat-gate-traffic-secret",
HY2XS_ADMIN_INITIAL_PASSWORD: "hy2xs-compat-gate-admin-password",
HY2XS_ADMIN_CON_PASS: "hy2xs-compat-gate-con-password"
};
if (options.certPath) {
overrides.HY2XS_TLS_CERT_PATH = options.certPath;
}
if (options.keyPath) {
overrides.HY2XS_TLS_KEY_PATH = options.keyPath;
}
if (options.hysteriaPort) {
overrides.HY2XS_HYSTERIA_PORT = options.hysteriaPort;
}
if (options.trafficStatsPort) {
overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort;
}
const sourceEnv = await Bun.file(options.configPath).text();
const config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
const context = {
mode: "install",
options: {
packageDir: options.packageDir,
sourceConfigPath: options.configPath,
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: true,
skipFirewall: true,
skipServiceStart: true,
skipSmoke: true
},
config,
packageVersion: "compat-gate",
packageBuildId: "compat-gate",
installDate: new Date().toISOString(),
hysteriaVersion: "compat-gate",
hysteriaResolution: "compat-gate"
} satisfies RuntimeContext;
const template = await Bun.file(options.templatePath).text();
await Bun.write(options.outPath, renderHysteriaConfig(context, template));
}
try {
await main();
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Build-time CLI: определяет upstream-релиз Hysteria и печатает env-строки.
*
* Запускается только на build machine из tools/build/lib/hysteria.sh.
* Target-сервер этот код никогда не выполняет.
*
* bun run tools/resolve-hysteria.ts --channel stable
* bun run tools/resolve-hysteria.ts --channel stable --version v2.12.2
*/
import {
HYSTERIA_UPSTREAM_REPO,
selectLatestStableRelease,
selectReleaseByVersion,
type GithubRelease,
type ResolvedHysteriaRelease
} from "../src/build/hysteriaRelease";
const RELEASES_PER_PAGE = 100;
const MAX_PAGES = 5;
function fail(message: string): never {
console.error(`[hy2xs-build] ERROR: ${message}`);
process.exit(1);
}
function parseArgs(argv: string[]): { channel: string; version: string } {
let channel = "stable";
let version = "";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--channel") {
channel = argv[i + 1] ?? "";
i += 1;
continue;
}
if (arg === "--version") {
version = argv[i + 1] ?? "";
i += 1;
continue;
}
fail(`unknown argument: ${arg}`);
}
if (channel !== "stable") {
fail(`unsupported HYSTERIA_CHANNEL for resolution: ${channel} (expected stable)`);
}
return { channel, version: version.trim() };
}
async function fetchReleases(): Promise<GithubRelease[]> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "hy2xs-build"
};
const token = (process.env.GITHUB_TOKEN ?? "").trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const releases: GithubRelease[] = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url = `https://api.github.com/repos/${HYSTERIA_UPSTREAM_REPO}/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`;
const response = await fetch(url, { headers });
if (response.status === 403 || response.status === 429) {
fail(
`GitHub API rate limit reached (HTTP ${response.status}). ` +
"Set GITHUB_TOKEN, or build with HYSTERIA_CHANNEL=pinned."
);
}
if (!response.ok) {
fail(`GitHub API request failed: HTTP ${response.status} for ${url}`);
}
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload)) {
fail(`unexpected GitHub API payload for ${url}`);
}
releases.push(...(payload as GithubRelease[]));
if (payload.length < RELEASES_PER_PAGE) {
break;
}
}
if (releases.length === 0) {
fail(`no releases returned for ${HYSTERIA_UPSTREAM_REPO}`);
}
return releases;
}
function emit(resolved: ResolvedHysteriaRelease, resolution: string): void {
// Значения читаются bash через eval-free парсинг, поэтому печатаем по строке.
console.log(`HYSTERIA_VERSION=${resolved.version}`);
console.log(`HYSTERIA_TAG=${resolved.tag}`);
console.log(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
console.log(`HYSTERIA_PUBLISHED_AT=${resolved.publishedAt}`);
console.log(`HYSTERIA_RESOLUTION=${resolution}`);
}
async function main(): Promise<void> {
const { version } = parseArgs(Bun.argv.slice(2));
const releases = await fetchReleases();
try {
if (version) {
emit(selectReleaseByVersion(releases, version), "override");
return;
}
emit(selectLatestStableRelease(releases), "latest-stable");
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
await main();