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