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:
@@ -1,5 +1,12 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FirewallMode, RuntimeConfig, TlsMode } from "../types/context";
|
||||
import {
|
||||
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
||||
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
||||
HY2XS_CONFIG_SCHEMA_VERSION,
|
||||
normalizeHysteriaObfsType,
|
||||
validateGeckoPacketSizes
|
||||
} from "./profile";
|
||||
|
||||
type EnvMap = Record<string, string>;
|
||||
|
||||
@@ -143,12 +150,22 @@ function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" {
|
||||
return "http";
|
||||
}
|
||||
|
||||
function normalizeFixedHysteriaObfsType(value: string | undefined): "salamander" {
|
||||
const obfsType = value || "salamander";
|
||||
if (obfsType !== "salamander") {
|
||||
throw new Error("HY2XS_HYSTERIA_OBFS_TYPE is fixed in HY2XS production profile: salamander");
|
||||
function normalizeConfigSchemaVersion(value: string | undefined): number {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) {
|
||||
return HY2XS_CONFIG_SCHEMA_VERSION;
|
||||
}
|
||||
return "salamander";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`invalid HY2XS_CONFIG_SCHEMA_VERSION: ${value}`);
|
||||
}
|
||||
if (parsed !== HY2XS_CONFIG_SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`unsupported HY2XS_CONFIG_SCHEMA_VERSION: ${parsed}. This package understands schema ${HY2XS_CONFIG_SCHEMA_VERSION}; ` +
|
||||
"HY2XS v1 requires a clean installation and does not migrate v0 configuration in place."
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeYamlSafeSecret(name: string, value: string): string {
|
||||
@@ -191,9 +208,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
const dnsAaaaPolicy = normalizeDnsAaaaPolicy(env.HY2XS_DNS_AAAA_POLICY);
|
||||
const firewallMode = normalizeFirewallMode(env);
|
||||
const hysteriaAuthMode = normalizeFixedHysteriaAuthMode(env.HY2XS_HYSTERIA_AUTH_MODE);
|
||||
const hysteriaObfsType = normalizeFixedHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
|
||||
const hysteriaObfsType = normalizeHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
|
||||
|
||||
const config: RuntimeConfig = {
|
||||
configSchemaVersion: normalizeConfigSchemaVersion(env.HY2XS_CONFIG_SCHEMA_VERSION),
|
||||
domain: env.HY2XS_DOMAIN || "",
|
||||
dnsAaaaPolicy,
|
||||
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
|
||||
@@ -232,6 +250,10 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
"HY2XS_HYSTERIA_OBFS_PASSWORD",
|
||||
valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)
|
||||
),
|
||||
// Gecko packet sizes не выносятся в env: share URI не умеет их передавать,
|
||||
// поэтому любое нестандартное значение сделало бы hysteria2:// неполным.
|
||||
hysteriaGeckoMinPacketSize: GECKO_DEFAULT_MIN_PACKET_SIZE,
|
||||
hysteriaGeckoMaxPacketSize: GECKO_DEFAULT_MAX_PACKET_SIZE,
|
||||
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
|
||||
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
|
||||
hysteriaIgnoreClientBandwidth: parseBool(
|
||||
@@ -251,6 +273,9 @@ export function parseRuntimeEnv(content: string): RuntimeConfig {
|
||||
}
|
||||
|
||||
export function validateRuntimeConfig(config: RuntimeConfig): void {
|
||||
if (config.hysteriaObfsType === "gecko") {
|
||||
validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
|
||||
}
|
||||
if (config.ipv6Enabled) {
|
||||
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false");
|
||||
}
|
||||
@@ -297,7 +322,8 @@ export function validateRuntimeConfig(config: RuntimeConfig): void {
|
||||
export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
const lines = [
|
||||
"# HY2XS runtime config (editable)",
|
||||
"HY2XS_IPV6_ENABLED=false",
|
||||
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
|
||||
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
|
||||
`HY2XS_DOMAIN=${config.domain}`,
|
||||
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
|
||||
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
|
||||
@@ -320,11 +346,11 @@ export function renderRuntimeEnv(config: RuntimeConfig): string {
|
||||
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
|
||||
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
|
||||
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
|
||||
"HY2XS_HYSTERIA_AUTH_MODE=http",
|
||||
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
|
||||
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
|
||||
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
|
||||
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
|
||||
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
|
||||
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
|
||||
|
||||
/**
|
||||
* HY2XS production profile: единственное место, где определены значения
|
||||
* серверной политики. Всё остальное (шаблоны, post-install.env, smoke,
|
||||
* build-time compatibility gate) только передаёт эти значения дальше.
|
||||
*/
|
||||
|
||||
export const HY2XS_CONFIG_SCHEMA_VERSION = 2;
|
||||
|
||||
export const HYSTERIA_OBFS_TYPES: readonly HysteriaObfsType[] = ["gecko", "salamander"];
|
||||
|
||||
/** Тип обфускации для новой установки. Salamander остаётся compatibility fallback. */
|
||||
export const DEFAULT_HYSTERIA_OBFS_TYPE: HysteriaObfsType = "gecko";
|
||||
|
||||
/** Upstream defaults Gecko. HY2XS фиксирует их явно как tested production profile. */
|
||||
export const GECKO_DEFAULT_MIN_PACKET_SIZE = 512;
|
||||
export const GECKO_DEFAULT_MAX_PACKET_SIZE = 1200;
|
||||
|
||||
/** Upstream ограничение: maxPacketSize >= minPacketSize и <= 2048. */
|
||||
export const GECKO_MAX_PACKET_SIZE_LIMIT = 2048;
|
||||
|
||||
/**
|
||||
* Fallback congestion controller. Используется, когда Brutal bandwidth
|
||||
* не согласован сторонами; сам Brutal включается через bandwidth up/down.
|
||||
*/
|
||||
export const CONGESTION_TYPE = "bbr";
|
||||
export const BBR_PROFILE = "standard";
|
||||
|
||||
/**
|
||||
* Loss compensation оставлен включённым (upstream default), поэтому
|
||||
* в конфиге явно фиксируется disableLossCompensation: false.
|
||||
*/
|
||||
export const DISABLE_LOSS_COMPENSATION = false;
|
||||
|
||||
/**
|
||||
* QUIC stateless reset нужен HY2XS: клиент со stale-соединением после
|
||||
* перезапуска сервера или сна устройства переподключается сразу.
|
||||
*/
|
||||
export const DISABLE_STATELESS_RESET = false;
|
||||
|
||||
export const QUIC_BASELINE = {
|
||||
initStreamReceiveWindow: 8388608,
|
||||
maxStreamReceiveWindow: 8388608,
|
||||
initConnReceiveWindow: 20971520,
|
||||
maxConnReceiveWindow: 20971520,
|
||||
maxIdleTimeout: "30s",
|
||||
maxIncomingStreams: 1024,
|
||||
disablePathMTUDiscovery: false
|
||||
} as const;
|
||||
|
||||
export function isHysteriaObfsType(value: string): value is HysteriaObfsType {
|
||||
return (HYSTERIA_OBFS_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function normalizeHysteriaObfsType(value: string | undefined): HysteriaObfsType {
|
||||
const obfsType = (value ?? "").trim() || DEFAULT_HYSTERIA_OBFS_TYPE;
|
||||
if (!isHysteriaObfsType(obfsType)) {
|
||||
throw new Error(
|
||||
`invalid HY2XS_HYSTERIA_OBFS_TYPE: ${value} (supported: ${HYSTERIA_OBFS_TYPES.join(", ")})`
|
||||
);
|
||||
}
|
||||
return obfsType;
|
||||
}
|
||||
|
||||
export function validateGeckoPacketSizes(minPacketSize: number, maxPacketSize: number): void {
|
||||
if (!Number.isInteger(minPacketSize) || minPacketSize <= 0) {
|
||||
throw new Error(`invalid Gecko minPacketSize: ${minPacketSize} (must be a positive integer)`);
|
||||
}
|
||||
if (!Number.isInteger(maxPacketSize) || maxPacketSize <= 0) {
|
||||
throw new Error(`invalid Gecko maxPacketSize: ${maxPacketSize} (must be a positive integer)`);
|
||||
}
|
||||
if (maxPacketSize < minPacketSize) {
|
||||
throw new Error(
|
||||
`invalid Gecko packet sizes: maxPacketSize ${maxPacketSize} must be >= minPacketSize ${minPacketSize}`
|
||||
);
|
||||
}
|
||||
if (maxPacketSize > GECKO_MAX_PACKET_SIZE_LIMIT) {
|
||||
throw new Error(
|
||||
`invalid Gecko maxPacketSize: ${maxPacketSize} (upstream limit is ${GECKO_MAX_PACKET_SIZE_LIMIT})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertYamlSafeQuoted(name: string, value: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`missing required ${name}`);
|
||||
}
|
||||
if (/["\n\r]/.test(value)) {
|
||||
throw new Error(`${name} contains forbidden characters for HY2XS YAML profile`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит целиком проверенный obfs-блок. Type selector никогда не собирается
|
||||
* внутри статического YAML, поэтому комбинация вида `type: gecko` + `salamander:`
|
||||
* структурно невозможна.
|
||||
*/
|
||||
export function renderObfsBlock(config: RuntimeConfig): string {
|
||||
const password = assertYamlSafeQuoted("HY2XS_HYSTERIA_OBFS_PASSWORD", config.hysteriaObfsPassword);
|
||||
|
||||
if (config.hysteriaObfsType === "gecko") {
|
||||
validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
|
||||
return [
|
||||
"obfs:",
|
||||
" type: gecko",
|
||||
" gecko:",
|
||||
` password: "${password}"`,
|
||||
` minPacketSize: ${config.hysteriaGeckoMinPacketSize}`,
|
||||
` maxPacketSize: ${config.hysteriaGeckoMaxPacketSize}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (config.hysteriaObfsType === "salamander") {
|
||||
return ["obfs:", " type: salamander", " salamander:", ` password: "${password}"`].join("\n");
|
||||
}
|
||||
|
||||
throw new Error(`unsupported obfs type: ${config.hysteriaObfsType satisfies never}`);
|
||||
}
|
||||
|
||||
export function renderCongestionBlock(): string {
|
||||
return ["congestion:", ` type: ${CONGESTION_TYPE}`, ` bbrProfile: ${BBR_PROFILE}`].join("\n");
|
||||
}
|
||||
|
||||
export function renderQuicBlock(): string {
|
||||
return [
|
||||
"quic:",
|
||||
` initStreamReceiveWindow: ${QUIC_BASELINE.initStreamReceiveWindow}`,
|
||||
` maxStreamReceiveWindow: ${QUIC_BASELINE.maxStreamReceiveWindow}`,
|
||||
` initConnReceiveWindow: ${QUIC_BASELINE.initConnReceiveWindow}`,
|
||||
` maxConnReceiveWindow: ${QUIC_BASELINE.maxConnReceiveWindow}`,
|
||||
` maxIdleTimeout: ${QUIC_BASELINE.maxIdleTimeout}`,
|
||||
` maxIncomingStreams: ${QUIC_BASELINE.maxIncomingStreams}`,
|
||||
` disablePathMTUDiscovery: ${QUIC_BASELINE.disablePathMTUDiscovery}`,
|
||||
` disableStatelessReset: ${DISABLE_STATELESS_RESET}`
|
||||
].join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user