fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `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
This commit is contained in:
@@ -262,6 +262,20 @@ function codeOf(source: string): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* То же для SFC: у шаблона свои комментарии.
|
||||
*
|
||||
* `codeOf` отбрасывает только комментарии JS/TS, а в `<template>` они
|
||||
* записываются как `<!-- … -->`. Комментарий, объясняющий, почему на этом
|
||||
* месте больше нет выдуманного `:9999`, обязан называть его по имени — и
|
||||
* проверка «такого значения в файле нет» падала ровно на той документации,
|
||||
* которая описывает выполненную ею же работу. Это тот же урок, что уже записан
|
||||
* в `codeOf`, просто повторившийся этажом выше.
|
||||
*/
|
||||
function vueCodeOf(source: string): string {
|
||||
return codeOf(source.replace(/<!--[\s\S]*?-->/g, ""));
|
||||
}
|
||||
|
||||
const LOG_PAGES = [
|
||||
["views", "log", "system", "index.vue"],
|
||||
["views", "log", "hysteria", "index.vue"],
|
||||
@@ -478,6 +492,120 @@ describe("действия над пиром", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("страница конфигурации Hysteria", () => {
|
||||
const configPage = () => readSource("views", "hysteria", "list", "index.vue");
|
||||
const configTypes = () => readSource("api", "config", "types.ts");
|
||||
|
||||
// Страница показывает ФАЙЛ, а не представление о файле.
|
||||
//
|
||||
// Прежняя версия строила форму merge'ем ответа сервера поверх полного
|
||||
// объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
|
||||
// показывалась как `:9999`, а `speedTest: false` считался ненастроенным.
|
||||
// Диагностический экран скрывал ровно тот дрейф, ради которого его
|
||||
// открывают.
|
||||
test("синтетических значений по умолчанию не осталось", () => {
|
||||
const source = vueCodeOf(configPage());
|
||||
|
||||
expect(source).not.toContain(":9999");
|
||||
expect(source).not.toContain("normalizeHysteriaViewModel");
|
||||
expect(source).not.toContain("defaultHysteria2ServerConfigView");
|
||||
|
||||
// Сам модуль view-модели удалён вместе с дефолтами: пока он существует,
|
||||
// его легко подключить обратно.
|
||||
expect(
|
||||
fs.existsSync(path.join(FRONTEND_SRC, "api", "config", "hysteriaViewModel.ts"))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Конфигом владеет оркестратор, маршрутов записи в API нет. Поля ввода на
|
||||
// такой странице обещают оператору действие, которого не существует.
|
||||
test("страница не изображает редактируемую форму", () => {
|
||||
const source = vueCodeOf(configPage());
|
||||
|
||||
for (const editable of ["<el-input", "<el-switch", "<el-select", "v-model"]) {
|
||||
expect(source).not.toContain(editable);
|
||||
}
|
||||
});
|
||||
|
||||
// Расхождение — главный ответ этого экрана: секция вне профиля означает, что
|
||||
// файл правил не оркестратор.
|
||||
test("расхождение конфигурации показывается отдельно", () => {
|
||||
const source = configPage();
|
||||
|
||||
expect(source).toContain("profile.drift");
|
||||
expect(source).toContain("hysteria.driftTitle");
|
||||
});
|
||||
|
||||
// Отсутствие Traffic Stats API выключает лимит устройств, учёт трафика и
|
||||
// принудительное отключение разом. Раньше на этом месте показывался
|
||||
// выдуманный адрес.
|
||||
test("отсутствие trafficStats названо отказом, а не значением", () => {
|
||||
const source = configPage();
|
||||
|
||||
expect(source).toContain("hysteria.trafficStatsMissing");
|
||||
expect(source).toContain("trafficStatsIsLoopback");
|
||||
});
|
||||
|
||||
// Read-only экран не имеет права быть щедрее санитизированной выгрузки того
|
||||
// же конфига. Пароль обфускации, токены ACME DNS и учётные данные
|
||||
// outbound-прокси в ответе больше не приезжают.
|
||||
test("тип ответа не несёт секретов", () => {
|
||||
const types = codeOf(configTypes());
|
||||
|
||||
expect(types).toContain("passwordSet");
|
||||
expect(types).toContain("secretSet");
|
||||
expect(types).toContain("dnsConfigKeys");
|
||||
|
||||
expect(types).not.toMatch(/\bpassword\s*[?]?:/);
|
||||
expect(types).not.toMatch(/\bsecret\s*[?]?:/);
|
||||
// Секции, которых production-профиль не описывает, в модели ответа не
|
||||
// объявляются вовсе: они попадают в `drift` списком имён.
|
||||
for (const section of ["outbounds", "masquerade", "resolver", "sniff", "acl", "mimic", "realm"]) {
|
||||
expect(types).not.toContain(`${section}:`);
|
||||
}
|
||||
});
|
||||
|
||||
// Компоненты генерического редактора удалены вместе со страницей: пока они
|
||||
// существуют, «универсальный редактор Hysteria» отрастает заново.
|
||||
test("компоненты генерического редактора удалены", () => {
|
||||
for (const component of ["MapAdd", "ImputMultiple", "UnitSelect"]) {
|
||||
expect(fs.existsSync(path.join(FRONTEND_SRC, "components", component))).toBe(false);
|
||||
}
|
||||
expect(
|
||||
fs.existsSync(path.join(FRONTEND_SRC, "views", "hysteria", "list", "components"))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("правдивая диагностика", () => {
|
||||
// «Состояние неизвестно» и «служба остановлена» — разные ответы. Пока
|
||||
// состояние было булевым, сломанный systemctl при живой Hysteria показывал
|
||||
// критическую плашку «служба остановлена».
|
||||
test("дашборд показывает три состояния службы", () => {
|
||||
for (const dictionary of [ru, en] as const) {
|
||||
const states = (dictionary as any).dashboard?.serviceState ?? {};
|
||||
expect(typeof states.active).toBe("string");
|
||||
expect(typeof states.inactive).toBe("string");
|
||||
expect(typeof states.unknown).toBe("string");
|
||||
}
|
||||
|
||||
const dashboard = readSource("views", "dashboard", "index.vue");
|
||||
expect(dashboard).toContain("summary.hysteria.serviceState");
|
||||
// Доступность API — независимый факт, а не следствие состояния службы.
|
||||
expect(dashboard).toContain("summary.hysteria.apiReachable");
|
||||
});
|
||||
|
||||
// Отказ Traffic Stats API больше не изображается строками «офлайн»: это
|
||||
// ответ, уводящий оператора искать проблему у пользователей.
|
||||
test("список пиров отличает «офлайн» от «неизвестно»", () => {
|
||||
const peerList = readSource("views", "peer", "list", "index.vue");
|
||||
|
||||
expect(peerList).toContain("onlineState");
|
||||
expect(peerList).toContain("peer.onlineUnknown");
|
||||
expect(peerList).toContain("peer.onlineUnavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("подсказки формы пира", () => {
|
||||
// Подсказка обещала более узкий набор символов, чем принимает сервер: она
|
||||
// осталась от правила, действовавшего до EX-03. Оператор не имел причин
|
||||
|
||||
Reference in New Issue
Block a user