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
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, test } from "bun:test";
import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env";
import {
GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE,
HY2XS_CONFIG_SCHEMA_VERSION,
normalizeHysteriaObfsType,
validateGeckoPacketSizes
} from "../src/config/profile";
import { baselineConfig, envText } from "./fixtures";
describe("obfs type resolution", () => {
test("новая установка без явного значения выбирает gecko", () => {
const config = parseRuntimeEnv(envText({ HY2XS_HYSTERIA_OBFS_TYPE: null }));
expect(config.hysteriaObfsType).toBe("gecko");
});
test("явный gecko принимается", () => {
expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" }).hysteriaObfsType).toBe("gecko");
});
test("явный salamander принимается как compatibility fallback", () => {
expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" }).hysteriaObfsType).toBe("salamander");
});
test("неизвестный тип отклоняется", () => {
expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "none" })).toThrow(
/invalid HY2XS_HYSTERIA_OBFS_TYPE/
);
});
test("тип нечувствителен к окружающим пробелам, но не к регистру", () => {
expect(normalizeHysteriaObfsType(" gecko ")).toBe("gecko");
expect(() => normalizeHysteriaObfsType("Gecko")).toThrow(/invalid HY2XS_HYSTERIA_OBFS_TYPE/);
});
test("пустой obfs-пароль означает автогенерацию, а не пустое значение в конфиге", () => {
for (const raw of ["", "__GENERATE__"]) {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: raw });
expect(config.hysteriaObfsPassword).not.toBe("");
expect(config.hysteriaObfsPassword).not.toBe("__GENERATE__");
expect(config.hysteriaObfsPassword.length).toBeGreaterThanOrEqual(24);
}
});
test("два разбора генерируют разные пароли", () => {
const first = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
const second = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
expect(first.hysteriaObfsPassword).not.toBe(second.hysteriaObfsPassword);
});
test("obfs-пароль с кавычкой или переводом строки отклоняется", () => {
expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: 'pa"ss' })).toThrow(
/forbidden characters/
);
});
});
describe("gecko packet sizes", () => {
test("baseline фиксирует upstream defaults 512/1200", () => {
const config = baselineConfig();
expect(config.hysteriaGeckoMinPacketSize).toBe(512);
expect(config.hysteriaGeckoMaxPacketSize).toBe(1200);
expect(config.hysteriaGeckoMinPacketSize).toBe(GECKO_DEFAULT_MIN_PACKET_SIZE);
expect(config.hysteriaGeckoMaxPacketSize).toBe(GECKO_DEFAULT_MAX_PACKET_SIZE);
});
test("валидная пара принимается", () => {
expect(() => validateGeckoPacketSizes(512, 1200)).not.toThrow();
expect(() => validateGeckoPacketSizes(700, 700)).not.toThrow();
});
test("max < min отклоняется", () => {
expect(() => validateGeckoPacketSizes(1200, 512)).toThrow(/must be >= minPacketSize/);
});
test("max > 2048 отклоняется", () => {
expect(() => validateGeckoPacketSizes(512, 2049)).toThrow(/upstream limit is 2048/);
});
test("max == 2048 принимается как граничное значение", () => {
expect(() => validateGeckoPacketSizes(512, 2048)).not.toThrow();
});
test("неположительный min отклоняется", () => {
expect(() => validateGeckoPacketSizes(0, 1200)).toThrow(/positive integer/);
expect(() => validateGeckoPacketSizes(-1, 1200)).toThrow(/positive integer/);
});
test("нецелые значения отклоняются", () => {
expect(() => validateGeckoPacketSizes(512.5, 1200)).toThrow(/positive integer/);
});
});
describe("config schema version", () => {
test("отсутствие значения даёт текущую схему", () => {
const config = parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }));
expect(config.configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION);
});
test("схема v0/v1 отклоняется с указанием на чистую установку", () => {
expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "1" })).toThrow(
/requires a clean installation/
);
});
test("нечисловая схема отклоняется", () => {
expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "two" })).toThrow(
/invalid HY2XS_CONFIG_SCHEMA_VERSION/
);
});
});
describe("renderRuntimeEnv round-trip", () => {
// Регрессия: renderRuntimeEnv раньше печатал литерал `salamander`, из-за чего
// записанный на диск runtime-конфиг расходился с разобранным.
test("gecko переживает render → parse без потерь", () => {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
expect(reparsed).toEqual(config);
});
test("salamander переживает render → parse без подмены на дефолт", () => {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
const rendered = renderRuntimeEnv(config);
expect(rendered).toContain("HY2XS_HYSTERIA_OBFS_TYPE=salamander");
expect(rendered).not.toContain("HY2XS_HYSTERIA_OBFS_TYPE=gecko");
expect(parseRuntimeEnv(rendered)).toEqual(config);
});
test("render не теряет ни одно поле runtime-конфига", () => {
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
for (const key of Object.keys(config) as (keyof typeof config)[]) {
expect(reparsed[key]).toEqual(config[key]);
}
});
test("схема конфигурации попадает в runtime-файл", () => {
expect(renderRuntimeEnv(baselineConfig())).toContain(
`HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}`
);
});
});
describe("остальные production-инварианты", () => {
test("IPv6 остаётся запрещённым", () => {
expect(() => baselineConfig({ HY2XS_IPV6_ENABLED: "true" })).toThrow(/IPv4-only/);
});
test("auth mode зафиксирован в http", () => {
expect(() => baselineConfig({ HY2XS_HYSTERIA_AUTH_MODE: "password" })).toThrow(
/HY2XS_HYSTERIA_AUTH_MODE is fixed/
);
});
test("ACME DNS challenge пока запрещён в production-профиле", () => {
expect(() => baselineConfig({ HY2XS_ACME_TYPE: "dns" })).toThrow(/not supported in production profile/);
});
});