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
+124
View File
@@ -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();
});
});
+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/);
});
});
+102
View File
@@ -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"
};
}
+220
View File
@@ -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 -");
});
});
+141
View File
@@ -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}}");
});
});