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,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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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 -");
|
||||
});
|
||||
});
|
||||
@@ -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}}");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user