353 lines
18 KiB
TypeScript
353 lines
18 KiB
TypeScript
import type { HysteriaObfsType, RuntimeConfig } from "../types/context";
|
||
import { isEnvTransportable } from "../lib/envFile";
|
||
|
||
/**
|
||
* HY2XS production profile: единственное место, где определены значения
|
||
* серверной политики. Всё остальное (шаблоны, post-install.env, smoke,
|
||
* build-time compatibility gate) только передаёт эти значения дальше.
|
||
*/
|
||
|
||
export const HY2XS_CONFIG_SCHEMA_VERSION = 2;
|
||
|
||
/**
|
||
* Линия релиза продукта. Меняется только при смене поколения, внутри которого
|
||
* установка остаётся совместимой сама с собой. Используется install-state,
|
||
* чтобы reconfigure/repair не работали поверх чужого поколения.
|
||
*
|
||
* Значение синхронизировано с HY2XS_RELEASE_LINE в корневом versions.env
|
||
* (проверяется шагом verify_versions_contract на сборке).
|
||
*/
|
||
export const HY2XS_RELEASE_LINE = 1;
|
||
|
||
/**
|
||
* Целевая платформа production-профиля. Значения синхронизированы с
|
||
* HY2XS_TARGET_OS_VERSION / HY2XS_TARGET_ARCH в корневом versions.env.
|
||
*/
|
||
export const HY2XS_TARGET_DEBIAN_VERSION = 13;
|
||
export const HY2XS_TARGET_ARCH = "amd64";
|
||
|
||
/**
|
||
* Пространства имён HTTP API админки.
|
||
*
|
||
* `HYSTERIA_MACHINE_AUTH_PATH` — не внутреннее имя переменной, а runtime-контракт
|
||
* продукта: оркестратор записывает этот путь в /etc/hysteria/config.yaml и в
|
||
* post-install.env, и по нему Hysteria спрашивает у админки разрешение на
|
||
* подключение пира. Раньше эта строка (в терминах H UI) была размазана по
|
||
* шаблонам, smoke, assertions, тестам, acceptance и e2e.
|
||
*
|
||
* Значения обязаны совпадать с константами админки
|
||
* (apps/model/constant/api.go). Сверка выполняется на сборке шагом
|
||
* verify_versions_contract через tools/print-contract.ts.
|
||
*/
|
||
export const ADMIN_API_BASE = "/api";
|
||
export const HYSTERIA_MACHINE_AUTH_PATH = "/internal/hysteria/auth";
|
||
|
||
/**
|
||
* Путь формы входа в панель. Смысл тот же, что у HYSTERIA_MACHINE_AUTH_PATH:
|
||
* это runtime-контракт продукта, по которому smoke проверяет, что установка
|
||
* оставила после себя РАБОТАЮЩУЮ панель, а не просто открытый порт.
|
||
*/
|
||
export const ADMIN_LOGIN_PATH = `${ADMIN_API_BASE}/auth/login`;
|
||
|
||
/**
|
||
* Контракт учётных данных администратора.
|
||
*
|
||
* Зачем он здесь. Оркестратор задаёт имя и первый пароль администратора, а
|
||
* принимает их панель — по правилам, которых оркестратор не знал вовсе.
|
||
* Следствие было не теоретическим: значением по умолчанию здесь стояло
|
||
* `admin` — пять символов при минимуме в шесть, — и такая установка
|
||
* завершалась `INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно
|
||
* войти. Проверять контракт обязана та сторона, которая значение ПОРОЖДАЕТ:
|
||
* отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на
|
||
* готовом сервере — переустановкой.
|
||
*
|
||
* Значения обязаны совпадать с apps/credential/admin.go; сверка выполняется
|
||
* тестом admin-credentials.test.ts, который читает Go-исходник.
|
||
*/
|
||
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||
export const ADMIN_USERNAME_MAX_LENGTH = 32;
|
||
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||
|
||
/**
|
||
* Предел bcrypt — в БАЙТАХ.
|
||
*
|
||
* Он существует одновременно с границей в символах и не выводится из неё: у 64
|
||
* символов длина от 64 до 256 байт в зависимости от алфавита. Пока оркестратор
|
||
* знал только границу в символах, он пропускал в hy2xs.env пароль вроде 64
|
||
* кириллических букв (128 байт), установка проходила целиком, а первая учётная
|
||
* запись администратора не создавалась вовсе — bcrypt отвечал
|
||
* ErrPasswordTooLong уже внутри админки, при старте службы.
|
||
*/
|
||
export const ADMIN_PASSWORD_MAX_BYTES = 72;
|
||
|
||
/**
|
||
* Набор символов логина в записи регекспа.
|
||
*
|
||
* Дефис ЭКРАНИРОВАН намеренно. В исходной записи `_+-=` он экранирован не был,
|
||
* из-за чего `+-=` образовывал диапазон и молча впускал `, - . / 0-9 : ; < =`.
|
||
* Здесь перечислено то же самое ФАКТИЧЕСКОЕ множество, но явно: сужать его в
|
||
* одиночку нельзя — оно уже действует на установленных серверах.
|
||
*/
|
||
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
|
||
|
||
export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
|
||
);
|
||
|
||
/** Тот же набор в том виде, в каком его показывают оператору. */
|
||
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||
|
||
/**
|
||
* Проверка логина администратора против контракта панели.
|
||
*
|
||
* Возвращает значение, а не булево: вызывающий обязан использовать именно
|
||
* проверенное — с обрезанными краями, — иначе пробел из hy2xs.env уедет в базу
|
||
* и вход снова перестанет работать по причине, которую негде увидеть.
|
||
*/
|
||
export function assertValidAdminUsername(name: string, value: string): string {
|
||
const username = value.trim();
|
||
if (!ADMIN_USERNAME_PATTERN.test(username)) {
|
||
throw new Error(
|
||
`invalid ${name}: панель принимает от ${ADMIN_USERNAME_MIN_LENGTH} до ${ADMIN_USERNAME_MAX_LENGTH} ` +
|
||
`символов из набора ${ADMIN_USERNAME_CHARSET}. ` +
|
||
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||
);
|
||
}
|
||
return username;
|
||
}
|
||
|
||
/**
|
||
* Единственное правило пароля администратора на стороне оркестратора.
|
||
*
|
||
* Копия Go-контракта, и сверяется с ним по исходнику (admin-credentials.test.ts):
|
||
* оркестратор собирается отдельно от Go-бинарника и импортировать его константы
|
||
* не может.
|
||
*
|
||
* Правило состоит из трёх частей, и каждая закрывает свой класс отказа.
|
||
*
|
||
* Длина в CODE POINTS, а не в `String.length`. `String.length` считает единицы
|
||
* UTF-16, и пароль из трёх эмодзи имел бы здесь длину 6 — прошёл бы минимум и
|
||
* получил отказ на форме входа, где сервер видит три руны.
|
||
*
|
||
* Длина в БАЙТАХ — предел bcrypt. Пока его не было, пароль из 64 кириллических
|
||
* букв проходил установку целиком, а учётная запись администратора не
|
||
* создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор
|
||
* получал не отказ установки, а сервер без администратора.
|
||
*
|
||
* Управляющие символы формат EnvironmentFile способен нести; их отвергает
|
||
* политика HY2XS, потому что невидимое значение нельзя надёжно повторить в
|
||
* однострочной форме входа. Транспортный домен (включая U+FEFF) проверяется
|
||
* отдельно в lib/envFile.ts.
|
||
*
|
||
* Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни
|
||
* при установке, ни при смене пароля, и оркестратор не имеет права быть строже.
|
||
* Пробелы по краям — часть пароля и не снимаются.
|
||
*/
|
||
export function isValidAdminPassword(value: string): boolean {
|
||
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до админки
|
||
// вовсе — systemd откажется загружать /etc/hy2xs/hy2xs.env, и юнит не
|
||
// стартует. Это отказ более грубого рода, чем нарушение границ длины, и
|
||
// раньше контракт про него не знал: `abcde` плюс U+FDD0 — шесть символов,
|
||
// восемь байт, ни одного управляющего — проходило здесь, записывалось в файл
|
||
// и оставляло сервер без работающей панели.
|
||
if (!isEnvTransportable(value)) {
|
||
return false;
|
||
}
|
||
|
||
let characters = 0;
|
||
for (const character of value) {
|
||
const code = character.codePointAt(0) ?? 0;
|
||
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
|
||
// DEL, C1). U+FEFF уже отвергнут транспортным доменом выше.
|
||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
||
return false;
|
||
}
|
||
characters += 1;
|
||
}
|
||
|
||
if (characters < ADMIN_PASSWORD_MIN_LENGTH || characters > ADMIN_PASSWORD_MAX_LENGTH) {
|
||
return false;
|
||
}
|
||
|
||
return Buffer.byteLength(value, "utf8") <= ADMIN_PASSWORD_MAX_BYTES;
|
||
}
|
||
|
||
export function assertValidAdminPassword(name: string, value: string): string {
|
||
if (!isValidAdminPassword(value)) {
|
||
throw new Error(
|
||
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
|
||
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` +
|
||
`в UTF-8, без управляющих символов и U+FEFF; получено ${[...value].length} символов ` +
|
||
`и ${Buffer.byteLength(value, "utf8")} байт. ` +
|
||
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
|
||
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||
);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* Где оркестратор живёт на установленном хосте.
|
||
*
|
||
* Эти пути раскладывает шаг `bootstrapRuntime` в самом начале PHASE 1, и они же
|
||
* являются clean-host маркерами чужой установки. Объявлены здесь один раз
|
||
* именно потому, что у них два потребителя с противоположными ролями:
|
||
* steps/bootstrap.ts их создаёт, steps/cleanHost.ts на них отказывает.
|
||
* Разошедшиеся копии означали бы, что установка создаёт путь, который её
|
||
* собственный контракт чистоты не проверяет.
|
||
*/
|
||
export const ORCHESTRATOR_INSTALL_DIR = "/usr/local/lib/hy2xs";
|
||
export const ORCHESTRATOR_INSTALL_PATH = `${ORCHESTRATOR_INSTALL_DIR}/hy2xs-orchestrator`;
|
||
export const ORCHESTRATOR_SYMLINK_PATH = "/usr/local/bin/hy2xs-orchestrator";
|
||
export const RUNTIME_PACKAGE_DIR = `${ORCHESTRATOR_INSTALL_DIR}/package`;
|
||
|
||
/**
|
||
* Каталоги пакета, которые обязаны пережить установку: reconfigure/repair/doctor
|
||
* работают уже от runtime-копии, а не от распакованного архива.
|
||
* `orchestrator/` сюда не входит намеренно — бинарник кладётся отдельно, в
|
||
* ORCHESTRATOR_INSTALL_PATH.
|
||
*/
|
||
export const RUNTIME_PACKAGE_CONTENTS: readonly string[] = [
|
||
"config",
|
||
"docs",
|
||
"systemd",
|
||
"templates",
|
||
"metadata",
|
||
"ui"
|
||
];
|
||
|
||
/** Полный machine-auth URL, который видит Hysteria. */
|
||
export function hysteriaMachineAuthUrl(uiPort: number, machineToken: string): string {
|
||
return `http://127.0.0.1:${uiPort}${HYSTERIA_MACHINE_AUTH_PATH}?access_token=${machineToken}`;
|
||
}
|
||
|
||
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");
|
||
}
|