a88268b0cd
fatal_pre_apply мог означать «хост уже изменён». install-state.json пишется сразу после успешного preflight, до установки пакетов, но классификация отказа его не учитывала. Падение apt-get объявлялось как «на сервере ничего не изменено»: откат и обработка состояния пропускались, а маркер оставался на диске и ломал следующую установку по clean-host контракту. Ownership-флаги переформулированы с «шаг успешно завершился» на «операция могла начать менять систему» и взводятся перед мутирующим вызовом: apt-get умеет изменить систему и упасть. fatal_pre_apply теперь недостижим ни при одном взведённом флаге, включая stateWritten. Read-only guard PHASE 0 можно было обойти. Guard стоял на writeText, writeTextAtomic, runVisible, runHidden и runRawVisible, но не на универсальном run, через который в коде проходили и наблюдение (ss, systemctl is-active), и настоящие мутации (useradd, install -d, mkdir, cp -a, tar). Универсального раннера больше нет: runReadOnly/runReadOnlySecret без guard'а и runMutating* под guard'ом, выбор — явное решение на месте вызова. clean-host не замечал часть того, что удаляет purge. /var/lib/hysteria с ACME-состоянием Hysteria, /var/log/hy2xs, /usr/local/lib/hy2xs и /usr/local/bin/hy2xs-orchestrator не были маркерами: сервер, где остался только старый runtime-state Hysteria, проходил проверку и получал свежую установку поверх чужого состояния. Пути, которые install.sh создаёт между фазами, помечены как созданные установщиком, иначе PHASE 1 отказала бы на собственном оркестраторе. purge-v0.sh --keep-hysteria-binary противоречил установщику: скрипт сохранял /usr/local/bin/hysteria и сообщал «хост чист для установки HY2XS v1», хотя clean-host считает этот бинарник legacy-маркером. Флаг удалён. DNS проверялся на существование A-записи, но не на то, куда она ведёт. После принудительной смены IPv4 провайдером doctor отвечал успехом, хотя клиентская ссылка отправляла людей на чужую машину. Проверялся при этом HY2XS_DOMAIN, тогда как в hysteria2:// уезжает HY2XS_PUBLIC_HOST. Добавлен инвариант публичного endpoint: A-записи обязаны принадлежать множеству публичных IPv4, назначенных интерфейсам этого сервера. Проверка живёт в общем preflight, поэтому действует в install, reconfigure и doctor. Адрес определяется локально, без внешних сервисов определения IP. Строгость управляется HY2XS_PUBLIC_ENDPOINT_POLICY (strict по умолчанию); отсутствие A-записи фатально при любом значении. TS-санитайзер приведён к той же формулировке, что и Go: URL-значение определяется по самому значению, а не по имени ключа.
177 lines
7.6 KiB
TypeScript
177 lines
7.6 KiB
TypeScript
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;
|
|
|
|
/**
|
|
* Линия релиза продукта. Меняется только при смене поколения, внутри которого
|
|
* установка остаётся совместимой сама с собой. Используется 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";
|
|
|
|
/** Полный 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");
|
|
}
|