feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin

Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1.

Build:
- версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и
  замораживается в metadata пакета (version + immutable url + sha256);
- compatibility gate: реальный бинарник должен принять канонический конфиг
  HY2XS для gecko и salamander до создания пакета;
- сборка прогоняет тесты оркестратора и админки.

Конфигурационный контракт:
- HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast;
- obfs стал настоящим union gecko|salamander, gecko — default;
- obfs-блок рендерится оркестратором целиком, два подтипа одновременно
  структурно невозможны;
- современный baseline: congestion bbr/standard, disableLossCompensation=false,
  disableStatelessReset=false, полный quic-блок.

Исправления:
- share URI для gecko: генератор был завязан на Obfs.Salamander.Password и
  выдавал нерабочую ссылку при любой другой обфускации;
- SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file;
- экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль;
- экспорт терял неизвестные upstream-поля при round-trip через типизированную
  модель;
- renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом;
- namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0).

Тесты:
- 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover;
- тесты URI и экспорта в Go;
- tools/test/e2e-hysteria.sh с реальным клиентом Hysteria.

UX:
- подсказки и примеры в форме создания пира.

Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов),
документация на русском.
This commit is contained in:
2026-08-27 08:15:02 +05:00
parent 0205334cd8
commit ddf0ddf71e
53 changed files with 4827 additions and 291 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* Build-time логика выбора upstream-релиза Hysteria.
*
* Этот модуль не входит в install-only orchestrator: target-сервер никогда
* не резолвит "latest" сам, он получает уже замороженные version/url/sha256.
*/
export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria";
export const HYSTERIA_LINUX_AMD64_ASSET = "hysteria-linux-amd64";
/** Upstream публикует несколько семейств тегов; серверный бинарник живёт в app/vX.Y.Z. */
export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/;
export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
export type GithubAsset = {
name: string;
browser_download_url: string;
};
export type GithubRelease = {
tag_name: string;
draft?: boolean;
prerelease?: boolean;
published_at?: string | null;
assets?: GithubAsset[];
};
export type ResolvedHysteriaRelease = {
/** Нормализованная версия вида v2.12.2. */
version: string;
/** Полный upstream-тег вида app/v2.12.2. */
tag: string;
/** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */
artifactUrl: string;
publishedAt: string;
};
type SemverParts = [number, number, number];
export function parseVersion(value: string): SemverParts | null {
const match = HYSTERIA_VERSION_PATTERN.exec(value.trim());
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function parseAppTag(tag: string): { version: string; parts: SemverParts } | null {
const match = HYSTERIA_APP_TAG_PATTERN.exec(tag.trim());
if (!match) {
return null;
}
const parts: SemverParts = [Number(match[1]), Number(match[2]), Number(match[3])];
return { version: `v${parts[0]}.${parts[1]}.${parts[2]}`, parts };
}
/** Числовое сравнение, а не лексикографическое: v2.9.10 новее v2.9.2. */
export function compareVersionParts(a: SemverParts, b: SemverParts): number {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) {
return a[i] < b[i] ? -1 : 1;
}
}
return 0;
}
function isStable(release: GithubRelease): boolean {
return release.draft !== true && release.prerelease !== true;
}
export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset {
const matches = (release.assets ?? []).filter((asset) => asset.name === HYSTERIA_LINUX_AMD64_ASSET);
if (matches.length === 0) {
throw new Error(
`upstream release ${release.tag_name} has no ${HYSTERIA_LINUX_AMD64_ASSET} asset`
);
}
if (matches.length > 1) {
throw new Error(
`upstream release ${release.tag_name} has ${matches.length} ambiguous ${HYSTERIA_LINUX_AMD64_ASSET} assets`
);
}
const url = matches[0].browser_download_url?.trim();
if (!url) {
throw new Error(`upstream release ${release.tag_name} has an empty asset download url`);
}
if (!url.startsWith("https://")) {
throw new Error(`upstream release ${release.tag_name} asset url is not https: ${url}`);
}
return { name: matches[0].name, browser_download_url: url };
}
function toResolved(release: GithubRelease, version: string): ResolvedHysteriaRelease {
return {
version,
tag: release.tag_name.trim(),
artifactUrl: selectLinuxAmd64Asset(release).browser_download_url,
publishedAt: (release.published_at ?? "").trim()
};
}
/**
* Выбирает последний стабильный app-релиз: без draft и prerelease, тег строго
* app/vX.Y.Z, наибольшая версия по числовому сравнению.
*/
export function selectLatestStableRelease(releases: GithubRelease[]): ResolvedHysteriaRelease {
let best: { release: GithubRelease; version: string; parts: SemverParts } | null = null;
for (const release of releases) {
if (!isStable(release)) {
continue;
}
const parsed = parseAppTag(release.tag_name ?? "");
if (!parsed) {
continue;
}
if (!best || compareVersionParts(parsed.parts, best.parts) > 0) {
best = { release, version: parsed.version, parts: parsed.parts };
}
}
if (!best) {
throw new Error(
`no stable ${HYSTERIA_UPSTREAM_REPO} release matching app/vX.Y.Z found in upstream release list`
);
}
return toResolved(best.release, best.version);
}
/** Выбирает конкретную версию по HYSTERIA_VERSION_OVERRIDE. */
export function selectReleaseByVersion(
releases: GithubRelease[],
version: string
): ResolvedHysteriaRelease {
const requested = parseVersion(version);
if (!requested) {
throw new Error(`invalid Hysteria version override: ${version} (expected vX.Y.Z)`);
}
const normalized = `v${requested[0]}.${requested[1]}.${requested[2]}`;
for (const release of releases) {
const parsed = parseAppTag(release.tag_name ?? "");
if (parsed?.version === normalized) {
if (release.draft === true) {
throw new Error(`Hysteria version override ${normalized} points to a draft release`);
}
return toResolved(release, parsed.version);
}
}
throw new Error(`Hysteria version override ${normalized} was not found in ${HYSTERIA_UPSTREAM_REPO}`);
}
export function renderLockEnv(
resolved: ResolvedHysteriaRelease,
sha256: string,
resolution: string,
resolvedAt: string
): string {
if (!/^[a-f0-9]{64}$/.test(sha256)) {
throw new Error(`invalid Hysteria artifact sha256: ${sha256}`);
}
return [
"# Файл генерируется tools/build/lib/hysteria.sh.",
"# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.",
`HYSTERIA_VERSION=${resolved.version}`,
`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`,
`HYSTERIA_ARTIFACT_SHA256=${sha256}`,
`HYSTERIA_RESOLUTION=${resolution}`,
`HYSTERIA_RESOLVED_AT=${resolvedAt}`,
""
].join("\n");
}