cb20d8d28f
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
236 lines
11 KiB
TypeScript
236 lines
11 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/);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Адрес Traffic Stats API — внутренний контракт двух компонентов, а не
|
||
* настройка.
|
||
*
|
||
* Проверка существует потому, что прежняя валидация была не просто мягкой, а
|
||
* согласованно неверной: env принимал любой IPv4, шаблон честно его
|
||
* подставлял, а `assertHysteriaConfigMatchesProfile` сверял конфиг с тем же
|
||
* значением. Все гейты проходили, а продукт при этом переставал работать —
|
||
* админка читает из `trafficStats.listen` только порт и всегда обращается на
|
||
* loopback, поэтому расхождение адресов выключает лимит устройств, учёт
|
||
* трафика и принудительное отключение разом (fail-closed: пиры не проходят
|
||
* авторизацию вовсе).
|
||
*/
|
||
describe("адрес Traffic Stats API", () => {
|
||
test("канонический 127.0.0.1 принимается", () => {
|
||
expect(baselineConfig().hysteriaTrafficStatsHost).toBe("127.0.0.1");
|
||
});
|
||
|
||
test("значение по умолчанию не зависит от наличия ключа", () => {
|
||
expect(baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: null }).hysteriaTrafficStatsHost).toBe("127.0.0.1");
|
||
});
|
||
|
||
// Wildcard достижим по loopback, поэтому связь компонентов он не ломает —
|
||
// но публикует внутренний control plane на всех интерфейсах, и при
|
||
// HY2XS_FIREWALL_MODE=external|off его не прикрывает ничто.
|
||
test("0.0.0.0 отклоняется", () => {
|
||
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "0.0.0.0" })).toThrow(
|
||
/TRAFFIC_STATS_HOST is fixed/
|
||
);
|
||
});
|
||
|
||
test("другой адрес loopback отклоняется", () => {
|
||
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "127.0.0.2" })).toThrow(
|
||
/TRAFFIC_STATS_HOST is fixed/
|
||
);
|
||
});
|
||
|
||
test("адрес LAN отклоняется", () => {
|
||
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "192.168.1.10" })).toThrow(
|
||
/TRAFFIC_STATS_HOST is fixed/
|
||
);
|
||
});
|
||
|
||
test("публичный адрес отклоняется", () => {
|
||
expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "203.0.113.10" })).toThrow(
|
||
/TRAFFIC_STATS_HOST is fixed/
|
||
);
|
||
});
|
||
|
||
// Порт настройкой остаётся: обе половины продукта берут его из одного и того
|
||
// же `trafficStats.listen`, поэтому разойтись по нему они не могут.
|
||
test("порт остаётся настраиваемым", () => {
|
||
expect(baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_PORT: "34712" }).hysteriaTrafficStatsPort).toBe(34712);
|
||
});
|
||
});
|