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-значение определяется по самому значению, а не по имени ключа.
142 lines
5.3 KiB
TypeScript
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}}");
|
|
});
|
|
});
|