feat(v1): Gecko-обфускация, latest-stable Hysteria на сборке и forward-compatible admin
Сквозная миграция HY2XS на современную Hysteria (2.12.2) и переход на v1. Build: - версия Hysteria резолвится на этапе сборки из HyNetworks/hysteria и замораживается в metadata пакета (version + immutable url + sha256); - compatibility gate: реальный бинарник должен принять канонический конфиг HY2XS для gecko и salamander до создания пакета; - сборка прогоняет тесты оркестратора и админки. Конфигурационный контракт: - HY2XS_CONFIG_SCHEMA_VERSION=2, чужая схема отклоняется fail-fast; - obfs стал настоящим union gecko|salamander, gecko — default; - obfs-блок рендерится оркестратором целиком, два подтипа одновременно структурно невозможны; - современный baseline: congestion bbr/standard, disableLossCompensation=false, disableStatelessReset=false, полный quic-блок. Исправления: - share URI для gecko: генератор был завязан на Obfs.Salamander.Password и выдавал нерабочую ссылку при любой другой обфускации; - SNI брался только из ACME-блока и уходил пустым при HY2XS_TLS_MODE=file; - экспорт конфига выносил trafficStats.secret, access_token и obfs-пароль; - экспорт терял неизвестные upstream-поля при round-trip через типизированную модель; - renderRuntimeEnv печатал тип обфускации литералом, расходясь с конфигом; - namedotcom удалён из ACME-реестра (нет в Hysteria с 2.11.0). Тесты: - 95 тестов оркестратора: env, рендер, семантика профиля, резолвер, rollover; - тесты URI и экспорта в Go; - tools/test/e2e-hysteria.sh с реальным клиентом Hysteria. UX: - подсказки и примеры в форме создания пира. Прочее: CHANGELOG.md, .gitattributes (LF для target-side файлов), документация на русском.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Build-time логика выбора upstream-релиза Hysteria.
|
||||
*
|
||||
* Этот модуль не входит в install-only orchestrator: target-сервер никогда
|
||||
* не резолвит "latest" сам, он получает уже замороженные version/url/sha256.
|
||||
*/
|
||||
|
||||
export const HYSTERIA_UPSTREAM_REPO = "HyNetworks/hysteria";
|
||||
export const HYSTERIA_LINUX_AMD64_ASSET = "hysteria-linux-amd64";
|
||||
|
||||
/** Upstream публикует несколько семейств тегов; серверный бинарник живёт в app/vX.Y.Z. */
|
||||
export const HYSTERIA_APP_TAG_PATTERN = /^app\/v(\d+)\.(\d+)\.(\d+)$/;
|
||||
export const HYSTERIA_VERSION_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
export type GithubAsset = {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
};
|
||||
|
||||
export type GithubRelease = {
|
||||
tag_name: string;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
assets?: GithubAsset[];
|
||||
};
|
||||
|
||||
export type ResolvedHysteriaRelease = {
|
||||
/** Нормализованная версия вида v2.12.2. */
|
||||
version: string;
|
||||
/** Полный upstream-тег вида app/v2.12.2. */
|
||||
tag: string;
|
||||
/** Immutable release asset URL ровно в том виде, как его отдал GitHub API. */
|
||||
artifactUrl: string;
|
||||
publishedAt: string;
|
||||
};
|
||||
|
||||
type SemverParts = [number, number, number];
|
||||
|
||||
export function parseVersion(value: string): SemverParts | null {
|
||||
const match = HYSTERIA_VERSION_PATTERN.exec(value.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
export function parseAppTag(tag: string): { version: string; parts: SemverParts } | null {
|
||||
const match = HYSTERIA_APP_TAG_PATTERN.exec(tag.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const parts: SemverParts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
return { version: `v${parts[0]}.${parts[1]}.${parts[2]}`, parts };
|
||||
}
|
||||
|
||||
/** Числовое сравнение, а не лексикографическое: v2.9.10 новее v2.9.2. */
|
||||
export function compareVersionParts(a: SemverParts, b: SemverParts): number {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isStable(release: GithubRelease): boolean {
|
||||
return release.draft !== true && release.prerelease !== true;
|
||||
}
|
||||
|
||||
export function selectLinuxAmd64Asset(release: GithubRelease): GithubAsset {
|
||||
const matches = (release.assets ?? []).filter((asset) => asset.name === HYSTERIA_LINUX_AMD64_ASSET);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(
|
||||
`upstream release ${release.tag_name} has no ${HYSTERIA_LINUX_AMD64_ASSET} asset`
|
||||
);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`upstream release ${release.tag_name} has ${matches.length} ambiguous ${HYSTERIA_LINUX_AMD64_ASSET} assets`
|
||||
);
|
||||
}
|
||||
|
||||
const url = matches[0].browser_download_url?.trim();
|
||||
if (!url) {
|
||||
throw new Error(`upstream release ${release.tag_name} has an empty asset download url`);
|
||||
}
|
||||
if (!url.startsWith("https://")) {
|
||||
throw new Error(`upstream release ${release.tag_name} asset url is not https: ${url}`);
|
||||
}
|
||||
|
||||
return { name: matches[0].name, browser_download_url: url };
|
||||
}
|
||||
|
||||
function toResolved(release: GithubRelease, version: string): ResolvedHysteriaRelease {
|
||||
return {
|
||||
version,
|
||||
tag: release.tag_name.trim(),
|
||||
artifactUrl: selectLinuxAmd64Asset(release).browser_download_url,
|
||||
publishedAt: (release.published_at ?? "").trim()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает последний стабильный app-релиз: без draft и prerelease, тег строго
|
||||
* app/vX.Y.Z, наибольшая версия по числовому сравнению.
|
||||
*/
|
||||
export function selectLatestStableRelease(releases: GithubRelease[]): ResolvedHysteriaRelease {
|
||||
let best: { release: GithubRelease; version: string; parts: SemverParts } | null = null;
|
||||
|
||||
for (const release of releases) {
|
||||
if (!isStable(release)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseAppTag(release.tag_name ?? "");
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (!best || compareVersionParts(parsed.parts, best.parts) > 0) {
|
||||
best = { release, version: parsed.version, parts: parsed.parts };
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
throw new Error(
|
||||
`no stable ${HYSTERIA_UPSTREAM_REPO} release matching app/vX.Y.Z found in upstream release list`
|
||||
);
|
||||
}
|
||||
|
||||
return toResolved(best.release, best.version);
|
||||
}
|
||||
|
||||
/** Выбирает конкретную версию по HYSTERIA_VERSION_OVERRIDE. */
|
||||
export function selectReleaseByVersion(
|
||||
releases: GithubRelease[],
|
||||
version: string
|
||||
): ResolvedHysteriaRelease {
|
||||
const requested = parseVersion(version);
|
||||
if (!requested) {
|
||||
throw new Error(`invalid Hysteria version override: ${version} (expected vX.Y.Z)`);
|
||||
}
|
||||
|
||||
const normalized = `v${requested[0]}.${requested[1]}.${requested[2]}`;
|
||||
|
||||
for (const release of releases) {
|
||||
const parsed = parseAppTag(release.tag_name ?? "");
|
||||
if (parsed?.version === normalized) {
|
||||
if (release.draft === true) {
|
||||
throw new Error(`Hysteria version override ${normalized} points to a draft release`);
|
||||
}
|
||||
return toResolved(release, parsed.version);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Hysteria version override ${normalized} was not found in ${HYSTERIA_UPSTREAM_REPO}`);
|
||||
}
|
||||
|
||||
export function renderLockEnv(
|
||||
resolved: ResolvedHysteriaRelease,
|
||||
sha256: string,
|
||||
resolution: string,
|
||||
resolvedAt: string
|
||||
): string {
|
||||
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error(`invalid Hysteria artifact sha256: ${sha256}`);
|
||||
}
|
||||
|
||||
return [
|
||||
"# Файл генерируется tools/build/lib/hysteria.sh.",
|
||||
"# HYSTERIA_CHANNEL=pinned использует эти значения без обращения к сети.",
|
||||
`HYSTERIA_VERSION=${resolved.version}`,
|
||||
`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`,
|
||||
`HYSTERIA_ARTIFACT_SHA256=${sha256}`,
|
||||
`HYSTERIA_RESOLUTION=${resolution}`,
|
||||
`HYSTERIA_RESOLVED_AT=${resolvedAt}`,
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user