Files
HY2XS_flamy/orchestrator/test/render-config.test.ts
T
founder a88268b0cd fix(install): сделать границу «хост изменён» настоящим инвариантом
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-значение
определяется по самому значению, а не по имени ключа.
2026-08-27 20:49:38 +05:00

142 lines
5.3 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderHysteriaConfig } from "../src/steps/config";
import { renderObfsBlock } from "../src/config/profile";
import { baselineConfig, testContext } from "./fixtures";
const TEMPLATE = readFileSync(
join(import.meta.dir, "..", "..", "package", "templates", "hysteria", "config.yaml.tpl"),
"utf8"
);
function render(overrides: Record<string, string | null> = {}): string {
return renderHysteriaConfig(testContext(baselineConfig(overrides)), TEMPLATE);
}
describe("obfs block", () => {
test("gecko рендерит только gecko-подблок", () => {
const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
expect(yaml).toContain("obfs:\n type: gecko\n gecko:");
expect(yaml).toContain("minPacketSize: 512");
expect(yaml).toContain("maxPacketSize: 1200");
expect(yaml).not.toContain("salamander");
});
test("salamander рендерит только salamander-подблок", () => {
const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
expect(yaml).toContain("obfs:\n type: salamander\n salamander:");
expect(yaml).not.toContain("gecko");
expect(yaml).not.toContain("minPacketSize");
});
test("в конфиге никогда нет двух подтипов obfs одновременно", () => {
for (const obfsType of ["gecko", "salamander"]) {
const yaml = render({ HY2XS_HYSTERIA_OBFS_TYPE: obfsType });
const subtypes = [" gecko:", " salamander:"].filter((marker) => yaml.includes(marker));
expect(subtypes).toHaveLength(1);
}
});
test("obfs-пароль экранируется кавычками и попадает в оба профиля", () => {
for (const obfsType of ["gecko", "salamander"]) {
const yaml = render({
HY2XS_HYSTERIA_OBFS_TYPE: obfsType,
HY2XS_HYSTERIA_OBFS_PASSWORD: "p@ss:w#rd with spaces"
});
expect(yaml).toContain('password: "p@ss:w#rd with spaces"');
}
});
test("рендер блока защищается от YAML-инъекции даже в обход env-валидации", () => {
const config = baselineConfig();
const injected = { ...config, hysteriaObfsPassword: 'x"\nlisten: 0.0.0.0:1' };
expect(() => renderObfsBlock(injected)).toThrow(/forbidden characters/);
});
test("рендер отклоняет невалидные gecko-размеры, даже если они пришли из обхода env", () => {
const config = baselineConfig();
expect(() => renderObfsBlock({ ...config, hysteriaGeckoMaxPacketSize: 4096 })).toThrow(
/upstream limit is 2048/
);
});
});
describe("современный серверный baseline", () => {
test("bandwidth содержит явный disableLossCompensation: false", () => {
expect(render()).toContain("disableLossCompensation: false");
});
test("congestion фиксирует bbr/standard", () => {
const yaml = render();
expect(yaml).toContain("congestion:\n type: bbr\n bbrProfile: standard");
});
test("quic фиксирует disableStatelessReset: false", () => {
expect(render()).toContain("disableStatelessReset: false");
});
test("quic содержит полный набор baseline-полей", () => {
const yaml = render();
for (const field of [
"initStreamReceiveWindow: 8388608",
"maxStreamReceiveWindow: 8388608",
"initConnReceiveWindow: 20971520",
"maxConnReceiveWindow: 20971520",
"maxIdleTimeout: 30s",
"maxIncomingStreams: 1024",
"disablePathMTUDiscovery: false"
]) {
expect(yaml).toContain(field);
}
});
test("bandwidth-политика 50/50 сохраняется", () => {
const yaml = render();
expect(yaml).toContain('up: "50 mbps"');
expect(yaml).toContain('down: "50 mbps"');
expect(yaml).toContain("ignoreClientBandwidth: false");
});
});
describe("TLS-режимы", () => {
test("acme рендерит только acme-блок", () => {
const yaml = render({ HY2XS_TLS_MODE: "acme" });
expect(yaml).toMatch(/^acme:/m);
expect(yaml).not.toMatch(/^tls:/m);
expect(yaml).toContain("insecure: false");
});
test("file рендерит только tls-блок", () => {
const yaml = render({ HY2XS_TLS_MODE: "file" });
expect(yaml).toMatch(/^tls:/m);
expect(yaml).not.toMatch(/^acme:/m);
});
test("self_signed_dev включает insecure для локального auth", () => {
const yaml = render({
HY2XS_TLS_MODE: "self_signed_dev",
HY2XS_ALLOW_SELF_SIGNED_DEV: "true"
});
expect(yaml).toContain("insecure: true");
});
});
describe("шаблон", () => {
test("не осталось незаполненных плейсхолдеров", () => {
expect(render()).not.toMatch(/\{\{\s*[A-Z0-9_]+\s*\}\}/);
});
test("auth URL содержит machine access token", () => {
expect(render()).toContain(
"/internal/hysteria/auth?access_token=traffic-stats-secret"
);
});
test("шаблон не содержит захардкоженного типа обфускации", () => {
expect(TEMPLATE).not.toContain("salamander");
expect(TEMPLATE).not.toContain("gecko");
expect(TEMPLATE).toContain("{{OBFS_BLOCK}}");
});
});