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"); }