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
@@ -0,0 +1,171 @@
/**
* Build-time CLI: рендерит канонический серверный конфиг HY2XS тем же кодом,
* который работает на target-сервере.
*
* Используется compatibility gate: получившийся YAML скармливается реальному
* upstream-бинарнику Hysteria до того, как будет создан release package.
*
* bun run tools/render-canonical-config.ts \
* --package-dir . --obfs gecko --tls-mode file \
* --cert /tmp/x.crt --key /tmp/x.key --out /tmp/config.yaml
*/
import { parseRuntimeEnv } from "../src/config/env";
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
import type { RuntimeContext } from "../src/types/context";
type Options = {
packageDir: string;
configPath: string;
templatePath: string;
obfs: string;
tlsMode: string;
certPath: string;
keyPath: string;
hysteriaPort: string;
trafficStatsPort: string;
outPath: string;
};
function fail(message: string): never {
console.error(`[hy2xs-build] ERROR: ${message}`);
process.exit(1);
}
function parseArgs(argv: string[]): Options {
const options: Options = {
packageDir: "",
configPath: "",
templatePath: "",
obfs: "gecko",
tlsMode: "file",
certPath: "",
keyPath: "",
hysteriaPort: "",
trafficStatsPort: "",
outPath: ""
};
const flags: Record<string, keyof Options> = {
"--package-dir": "packageDir",
"--config": "configPath",
"--template": "templatePath",
"--obfs": "obfs",
"--tls-mode": "tlsMode",
"--cert": "certPath",
"--key": "keyPath",
"--port": "hysteriaPort",
"--traffic-stats-port": "trafficStatsPort",
"--out": "outPath"
};
for (let i = 0; i < argv.length; i += 1) {
const key = flags[argv[i]];
if (!key) {
fail(`unknown argument: ${argv[i]}`);
}
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) {
fail(`missing value for ${argv[i]}`);
}
options[key] = value;
i += 1;
}
if (!options.packageDir) {
fail("missing --package-dir");
}
if (!options.outPath) {
fail("missing --out");
}
if (!(HYSTERIA_OBFS_TYPES as readonly string[]).includes(options.obfs)) {
fail(`unsupported --obfs value: ${options.obfs}`);
}
options.configPath ||= `${options.packageDir}/config/hy2xs.env`;
options.templatePath ||= hysteriaConfigTemplatePath(options.packageDir);
return options;
}
function overrideEnv(source: string, overrides: Record<string, string>): string {
const pending = new Map(Object.entries(overrides));
const lines = source.split(/\r?\n/).map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
return line;
}
const key = trimmed.slice(0, trimmed.indexOf("="));
if (pending.has(key)) {
const value = pending.get(key) as string;
pending.delete(key);
return `${key}=${value}`;
}
return line;
});
for (const [key, value] of pending) {
lines.push(`${key}=${value}`);
}
return lines.join("\n");
}
async function main(): Promise<void> {
const options = parseArgs(Bun.argv.slice(2));
const overrides: Record<string, string> = {
HY2XS_HYSTERIA_OBFS_TYPE: options.obfs,
HY2XS_TLS_MODE: options.tlsMode,
// Compatibility gate работает офлайн и не должен зависеть от секретов пакета.
HY2XS_HYSTERIA_OBFS_PASSWORD: "hy2xs-compat-gate-obfs-password",
HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET: "hy2xs-compat-gate-traffic-secret",
HY2XS_ADMIN_INITIAL_PASSWORD: "hy2xs-compat-gate-admin-password",
HY2XS_ADMIN_CON_PASS: "hy2xs-compat-gate-con-password"
};
if (options.certPath) {
overrides.HY2XS_TLS_CERT_PATH = options.certPath;
}
if (options.keyPath) {
overrides.HY2XS_TLS_KEY_PATH = options.keyPath;
}
if (options.hysteriaPort) {
overrides.HY2XS_HYSTERIA_PORT = options.hysteriaPort;
}
if (options.trafficStatsPort) {
overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort;
}
const sourceEnv = await Bun.file(options.configPath).text();
const config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
const context = {
mode: "install",
options: {
packageDir: options.packageDir,
sourceConfigPath: options.configPath,
runtimeConfigPath: "/etc/hy2xs/hy2xs.env",
nonInteractive: true,
skipFirewall: true,
skipServiceStart: true,
skipSmoke: true
},
config,
packageVersion: "compat-gate",
packageBuildId: "compat-gate",
installDate: new Date().toISOString(),
hysteriaVersion: "compat-gate",
hysteriaResolution: "compat-gate"
} satisfies RuntimeContext;
const template = await Bun.file(options.templatePath).text();
await Bun.write(options.outPath, renderHysteriaConfig(context, template));
}
try {
await main();
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Build-time CLI: определяет upstream-релиз Hysteria и печатает env-строки.
*
* Запускается только на build machine из tools/build/lib/hysteria.sh.
* Target-сервер этот код никогда не выполняет.
*
* bun run tools/resolve-hysteria.ts --channel stable
* bun run tools/resolve-hysteria.ts --channel stable --version v2.12.2
*/
import {
HYSTERIA_UPSTREAM_REPO,
selectLatestStableRelease,
selectReleaseByVersion,
type GithubRelease,
type ResolvedHysteriaRelease
} from "../src/build/hysteriaRelease";
const RELEASES_PER_PAGE = 100;
const MAX_PAGES = 5;
function fail(message: string): never {
console.error(`[hy2xs-build] ERROR: ${message}`);
process.exit(1);
}
function parseArgs(argv: string[]): { channel: string; version: string } {
let channel = "stable";
let version = "";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--channel") {
channel = argv[i + 1] ?? "";
i += 1;
continue;
}
if (arg === "--version") {
version = argv[i + 1] ?? "";
i += 1;
continue;
}
fail(`unknown argument: ${arg}`);
}
if (channel !== "stable") {
fail(`unsupported HYSTERIA_CHANNEL for resolution: ${channel} (expected stable)`);
}
return { channel, version: version.trim() };
}
async function fetchReleases(): Promise<GithubRelease[]> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "hy2xs-build"
};
const token = (process.env.GITHUB_TOKEN ?? "").trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const releases: GithubRelease[] = [];
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url = `https://api.github.com/repos/${HYSTERIA_UPSTREAM_REPO}/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`;
const response = await fetch(url, { headers });
if (response.status === 403 || response.status === 429) {
fail(
`GitHub API rate limit reached (HTTP ${response.status}). ` +
"Set GITHUB_TOKEN, or build with HYSTERIA_CHANNEL=pinned."
);
}
if (!response.ok) {
fail(`GitHub API request failed: HTTP ${response.status} for ${url}`);
}
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload)) {
fail(`unexpected GitHub API payload for ${url}`);
}
releases.push(...(payload as GithubRelease[]));
if (payload.length < RELEASES_PER_PAGE) {
break;
}
}
if (releases.length === 0) {
fail(`no releases returned for ${HYSTERIA_UPSTREAM_REPO}`);
}
return releases;
}
function emit(resolved: ResolvedHysteriaRelease, resolution: string): void {
// Значения читаются bash через eval-free парсинг, поэтому печатаем по строке.
console.log(`HYSTERIA_VERSION=${resolved.version}`);
console.log(`HYSTERIA_TAG=${resolved.tag}`);
console.log(`HYSTERIA_ARTIFACT_URL=${resolved.artifactUrl}`);
console.log(`HYSTERIA_PUBLISHED_AT=${resolved.publishedAt}`);
console.log(`HYSTERIA_RESOLUTION=${resolution}`);
}
async function main(): Promise<void> {
const { version } = parseArgs(Bun.argv.slice(2));
const releases = await fetchReleases();
try {
if (version) {
emit(selectReleaseByVersion(releases, version), "override");
return;
}
emit(selectLatestStableRelease(releases), "latest-stable");
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
await main();