2b4a2cb2d5
Установщик мог повредить работающий сервер до того, как откажется его
трогать: install.sh переписывал /usr/local/lib/hy2xs, раскладывал
runtime-пакет и перезаписывал install-state.json, и только потом
запускал clean-host preflight. При ошибочном запуске поверх старой
установки rollback дополнительно делал stop и disable для работающих
hysteria-server и hy2xs-admin.
Установка разделена на две фазы с жёсткой границей:
PHASE 0 - read only: права, checksums пакета, clean-host preflight
из распакованного архива (новая команда preflight-install)
PHASE 1 - mutation: раскладка оркестратора и сама установка
Граница держится не соглашением, а read-only guard: под ним writeText,
writeTextAtomic и мутирующие раннеры lib/process кидают ошибку.
Внутри install() preflight выполняется раньше первой записи состояния.
Остальное в этом же инварианте:
- clean-host контракт расширен с двух маркеров до четырнадцати, пути
установки и данных берутся из конфигурации, а не захардкожены;
- отсутствие HY2XS_CONFIG_SCHEMA_VERSION трактуется как legacy, а не
как текущая схема: до v1 этого поля не существовало. Тест,
закреплявший прежнее поведение, инвертирован;
- install-state несёт идентификацию поколения (product, release_line,
config_schema_version); reconfigure и repair проверяют её до всего
остального, потому что installed: true мог остаться и от 0.x;
- repair требует явного --allow-partial-state;
- классификация отказа опирается на ownership-флаги, а не на текст
ошибки: раньше сообщение со словом nftables приводило к откату
чужого firewall. stop/disable выполняется только для юнитов,
развёрнутых текущей операцией, а fatal_pre_apply не делает
системного отката и не собирает diagnostics-бандл.
180 lines
7.9 KiB
TypeScript
180 lines
7.9 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env";
|
|
import {
|
|
GECKO_DEFAULT_MAX_PACKET_SIZE,
|
|
GECKO_DEFAULT_MIN_PACKET_SIZE,
|
|
HY2XS_CONFIG_SCHEMA_VERSION,
|
|
normalizeHysteriaObfsType,
|
|
validateGeckoPacketSizes
|
|
} from "../src/config/profile";
|
|
import { baselineConfig, envText } from "./fixtures";
|
|
|
|
describe("obfs type resolution", () => {
|
|
test("новая установка без явного значения выбирает gecko", () => {
|
|
const config = parseRuntimeEnv(envText({ HY2XS_HYSTERIA_OBFS_TYPE: null }));
|
|
expect(config.hysteriaObfsType).toBe("gecko");
|
|
});
|
|
|
|
test("явный gecko принимается", () => {
|
|
expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" }).hysteriaObfsType).toBe("gecko");
|
|
});
|
|
|
|
test("явный salamander принимается как compatibility fallback", () => {
|
|
expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" }).hysteriaObfsType).toBe("salamander");
|
|
});
|
|
|
|
test("неизвестный тип отклоняется", () => {
|
|
expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "none" })).toThrow(
|
|
/invalid HY2XS_HYSTERIA_OBFS_TYPE/
|
|
);
|
|
});
|
|
|
|
test("тип нечувствителен к окружающим пробелам, но не к регистру", () => {
|
|
expect(normalizeHysteriaObfsType(" gecko ")).toBe("gecko");
|
|
expect(() => normalizeHysteriaObfsType("Gecko")).toThrow(/invalid HY2XS_HYSTERIA_OBFS_TYPE/);
|
|
});
|
|
|
|
test("пустой obfs-пароль означает автогенерацию, а не пустое значение в конфиге", () => {
|
|
for (const raw of ["", "__GENERATE__"]) {
|
|
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: raw });
|
|
expect(config.hysteriaObfsPassword).not.toBe("");
|
|
expect(config.hysteriaObfsPassword).not.toBe("__GENERATE__");
|
|
expect(config.hysteriaObfsPassword.length).toBeGreaterThanOrEqual(24);
|
|
}
|
|
});
|
|
|
|
test("два разбора генерируют разные пароли", () => {
|
|
const first = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
|
|
const second = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" });
|
|
expect(first.hysteriaObfsPassword).not.toBe(second.hysteriaObfsPassword);
|
|
});
|
|
|
|
test("obfs-пароль с кавычкой или переводом строки отклоняется", () => {
|
|
expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: 'pa"ss' })).toThrow(
|
|
/forbidden characters/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("gecko packet sizes", () => {
|
|
test("baseline фиксирует upstream defaults 512/1200", () => {
|
|
const config = baselineConfig();
|
|
expect(config.hysteriaGeckoMinPacketSize).toBe(512);
|
|
expect(config.hysteriaGeckoMaxPacketSize).toBe(1200);
|
|
expect(config.hysteriaGeckoMinPacketSize).toBe(GECKO_DEFAULT_MIN_PACKET_SIZE);
|
|
expect(config.hysteriaGeckoMaxPacketSize).toBe(GECKO_DEFAULT_MAX_PACKET_SIZE);
|
|
});
|
|
|
|
test("валидная пара принимается", () => {
|
|
expect(() => validateGeckoPacketSizes(512, 1200)).not.toThrow();
|
|
expect(() => validateGeckoPacketSizes(700, 700)).not.toThrow();
|
|
});
|
|
|
|
test("max < min отклоняется", () => {
|
|
expect(() => validateGeckoPacketSizes(1200, 512)).toThrow(/must be >= minPacketSize/);
|
|
});
|
|
|
|
test("max > 2048 отклоняется", () => {
|
|
expect(() => validateGeckoPacketSizes(512, 2049)).toThrow(/upstream limit is 2048/);
|
|
});
|
|
|
|
test("max == 2048 принимается как граничное значение", () => {
|
|
expect(() => validateGeckoPacketSizes(512, 2048)).not.toThrow();
|
|
});
|
|
|
|
test("неположительный min отклоняется", () => {
|
|
expect(() => validateGeckoPacketSizes(0, 1200)).toThrow(/positive integer/);
|
|
expect(() => validateGeckoPacketSizes(-1, 1200)).toThrow(/positive integer/);
|
|
});
|
|
|
|
test("нецелые значения отклоняются", () => {
|
|
expect(() => validateGeckoPacketSizes(512.5, 1200)).toThrow(/positive integer/);
|
|
});
|
|
});
|
|
|
|
describe("config schema version", () => {
|
|
// Отсутствующий маркер — самый вероятный признак конфигурации 0.x: до v1
|
|
// этого поля не существовало. Любой fallback здесь молча принял бы legacy.
|
|
test("отсутствие значения трактуется как legacy и отклоняется", () => {
|
|
expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow(
|
|
/HY2XS_CONFIG_SCHEMA_VERSION отсутствует/
|
|
);
|
|
});
|
|
|
|
test("отказ по отсутствующей схеме указывает на чистую установку", () => {
|
|
expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow(
|
|
/legacy-cleanup/
|
|
);
|
|
});
|
|
|
|
test("пустое значение отклоняется так же, как отсутствующее", () => {
|
|
expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: "" }))).toThrow(
|
|
/HY2XS_CONFIG_SCHEMA_VERSION отсутствует/
|
|
);
|
|
});
|
|
|
|
test("текущая схема принимается", () => {
|
|
expect(baselineConfig().configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION);
|
|
});
|
|
|
|
test("схема v0/v1 отклоняется с указанием на чистую установку", () => {
|
|
expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "1" })).toThrow(
|
|
/requires a clean installation/
|
|
);
|
|
});
|
|
|
|
test("нечисловая схема отклоняется", () => {
|
|
expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "two" })).toThrow(
|
|
/invalid HY2XS_CONFIG_SCHEMA_VERSION/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("renderRuntimeEnv round-trip", () => {
|
|
// Регрессия: renderRuntimeEnv раньше печатал литерал `salamander`, из-за чего
|
|
// записанный на диск runtime-конфиг расходился с разобранным.
|
|
test("gecko переживает render → parse без потерь", () => {
|
|
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" });
|
|
const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
|
|
expect(reparsed).toEqual(config);
|
|
});
|
|
|
|
test("salamander переживает render → parse без подмены на дефолт", () => {
|
|
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
|
|
const rendered = renderRuntimeEnv(config);
|
|
expect(rendered).toContain("HY2XS_HYSTERIA_OBFS_TYPE=salamander");
|
|
expect(rendered).not.toContain("HY2XS_HYSTERIA_OBFS_TYPE=gecko");
|
|
expect(parseRuntimeEnv(rendered)).toEqual(config);
|
|
});
|
|
|
|
test("render не теряет ни одно поле runtime-конфига", () => {
|
|
const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" });
|
|
const reparsed = parseRuntimeEnv(renderRuntimeEnv(config));
|
|
for (const key of Object.keys(config) as (keyof typeof config)[]) {
|
|
expect(reparsed[key]).toEqual(config[key]);
|
|
}
|
|
});
|
|
|
|
test("схема конфигурации попадает в runtime-файл", () => {
|
|
expect(renderRuntimeEnv(baselineConfig())).toContain(
|
|
`HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}`
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("остальные production-инварианты", () => {
|
|
test("IPv6 остаётся запрещённым", () => {
|
|
expect(() => baselineConfig({ HY2XS_IPV6_ENABLED: "true" })).toThrow(/IPv4-only/);
|
|
});
|
|
|
|
test("auth mode зафиксирован в http", () => {
|
|
expect(() => baselineConfig({ HY2XS_HYSTERIA_AUTH_MODE: "password" })).toThrow(
|
|
/HY2XS_HYSTERIA_AUTH_MODE is fixed/
|
|
);
|
|
});
|
|
|
|
test("ACME DNS challenge пока запрещён в production-профиле", () => {
|
|
expect(() => baselineConfig({ HY2XS_ACME_TYPE: "dns" })).toThrow(/not supported in production profile/);
|
|
});
|
|
});
|