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:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+128
View File
@@ -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. Оператор не имел причин