Files
HY2XS_flamy/orchestrator/src/config/profile.ts
T

353 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}