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
+234 -4
View File
@@ -1663,7 +1663,7 @@ run_access_revocation_acceptance() {
# util.Exec схлопывает «systemctl вернул 3» и «запустить systemctl не
# удалось» в одну ошибку, поэтому на этом значении нельзя строить решения:
# сломанный systemctl при живой Hysteria молча отключал учёт и enforcement.
! code_has apps/service/cron.go -F -- 'Hysteria2IsRunning' \
! code_has apps/service/cron.go -E -- 'Hysteria2(IsRunning|ServiceState)\(' \
|| fail "acceptance: the account cron job must not gate on the systemd state"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
@@ -1675,7 +1675,7 @@ run_access_revocation_acceptance() {
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
if (body.includes("Hysteria2IsRunning")) {
if (/Hysteria2(IsRunning|ServiceState)\(|hysteriaServiceState\(/.test(body)) {
throw new Error("auth gates on the systemd state");
}
' || fail "acceptance: auth must not gate on the systemd state"
@@ -1721,7 +1721,21 @@ run_access_revocation_acceptance() {
// Замок обязан быть взят ДО чтения статистики: взятый после него он
// защищал бы только резервацию, а переупорядочиваются именно снимки.
if (gate > online) throw new Error("the admission gate is taken after the /online read");
if (!/defer\s+\w+\(\)/.test(body)) throw new Error("the admission gate is never released");
// Освобождение проверяется ИМЕННО ЭТОГО замка.
//
// Здесь стояло `/defer\s+\w+\(\)/`, то есть «в функции есть какой-нибудь
// отложенный вызов». Такой гейт пережил бы
//
// unlockAdmission := lockPeerAdmission(...)
// defer someOtherCleanup()
//
// — то есть замок, который не отпускается никогда. Имя переменной берётся
// из самого присваивания, поэтому её переименование гейт не ломает.
const binding = body.match(/(\w+)\s*:=\s*lockPeerAdmission\(/);
if (!binding) throw new Error("the admission gate result is not bound to a variable");
if (!new RegExp("defer\\s+" + binding[1] + "\\(\\)").test(body)) {
throw new Error("the admission gate is never released");
}
// Замок именно по authId. Литеральный ключ означал бы один замок на
// процесс, то есть очередь из подключений ВСЕХ пиров за одним HTTP-обменом.
if (!/lockPeerAdmission\(\*peer\.AuthId\)/.test(body)) {
@@ -1947,7 +1961,7 @@ run_access_revocation_acceptance() {
}
' || fail "acceptance: the device limit must be fail-closed"
code_has apps/service/hysteria2.go -F -- 'var hysteria2IsRunning' \
code_has apps/service/hysteria2.go -F -- 'var hysteriaServiceState' \
|| fail "acceptance: the systemd state seam is missing, so fail-closed is untestable"
log_step "Acceptance: the public endpoint error names no transport"
@@ -1955,6 +1969,222 @@ run_access_revocation_acceptance() {
# сообщение заведомо ложную семантику.
! code_has apps/service/hysteria2_api.go -F -- 'must be a valid TCP port' \
|| fail "acceptance: the public port error must not claim a TCP transport"
log_step "Acceptance: rotating a credential rotates the session identity"
# Отзыв секрета состоит из двух шагов — записать новый digest и завершить
# старые сессии. Второй шаг может не удаться, и это нормально: сходимость
# обязан обеспечить cron. Но сверять ему было нечем, пока `auth_id`
# оставался прежним: сессия по отозванному секрету выглядела законной по
# всем признакам.
#
# Хуже того, у этого состояния есть путь БЕЗ единой неудачи. Hysteria
# регистрирует соединение в Traffic Stats API только ПОСЛЕ возврата
# backend-auth (app/v2.12.2), поэтому `/kick`, прошедший в это окно, его не
# видит. Атомарной пары «решение авторизации + регистрация онлайна» upstream
# не даёт, и повторное чтение базы перед ответом окно не закрывает.
code_has apps/service/peer_secret.go -F -- 'func newPeerAuthID' \
|| fail "acceptance: the single auth_id generator is missing"
code_has apps/service/peer_secret.go -F -- 'func credentialGenerationChanged' \
|| fail "acceptance: the credential generation rule is missing"
# Генератор ровно один: `util.RandomString(18)` россыпью по местам создания
# означал бы, что ротация и создание умеют разойтись.
# Область скана — только исходники Go: `apps` целиком втянул бы
# node_modules и dist фронтенда.
local auth_id_generators
auth_id_generators="$(code_mentions_in 'RandomString(18)' \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
[ -z "$auth_id_generators" ] \
|| fail "acceptance: auth_id is generated outside newPeerAuthID: $auth_id_generators"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
const bodyOf = (name) => {
const start = source.indexOf(name);
if (start < 0) throw new Error(name + " is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
return (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
};
// Форма записи: новая идентичность попадает в ТУ ЖЕ операцию, что и новый
// digest, — иначе между ними появляется состояние «секрет уже сменён,
// сессии ещё старые», которое ничем не отличить от законного.
for (const fn of ["func UpdatePeer", "func applyPeerImportEntry"]) {
const body = bodyOf(fn);
if (!body.includes("credentialGenerationChanged(")) {
throw new Error(fn + " does not check the credential generation");
}
if (!/updates\["auth_id"\]\s*=/.test(body)) {
throw new Error(fn + " does not rotate the session identity with the secret");
}
}
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
// сессию. Разрыв по новому не завершил бы ничего.
const update = bodyOf("func UpdatePeer");
if (!update.includes("reconcileLiveSessions([]string{authIDOf(before)})")) {
throw new Error("UpdatePeer no longer disconnects the previous session identity");
}
' || fail "acceptance: a rotated secret must not keep the old session identity"
log_step "Acceptance: the Traffic Stats API address is one contract"
# Оркестратор принимал любой IPv4, шаблон честно его подставлял, а проверка
# профиля сверяла конфиг с тем же значением: все гейты проходили, а продукт
# переставал работать — админка читает из `trafficStats.listen` только порт
# и всегда идёт на loopback.
code_has orchestrator/src/config/env.ts -F -- 'TRAFFIC_STATS_HOST is fixed' \
|| fail "acceptance: the orchestrator no longer fixes the Traffic Stats host"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
const start = source.indexOf("export function validateRuntimeConfig");
if (start < 0) throw new Error("validateRuntimeConfig is missing");
const body = source.slice(start);
if (!/config\.hysteriaTrafficStatsHost\s*!==\s*"127\.0\.0\.1"/.test(body)) {
throw new Error("the Traffic Stats host is not pinned to loopback");
}
' || fail "acceptance: HY2XS_HYSTERIA_TRAFFIC_STATS_HOST must be pinned to 127.0.0.1"
grep -q '^HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127\.0\.0\.1$' package/config/hy2xs.env \
|| fail "acceptance: the packaged env no longer pins the Traffic Stats host"
# Вторая половина контракта: админка не подставляет loopback молча вместо
# прочитанного адреса, а называет расхождение.
code_has apps/service/config.go -F -- 'func assertTrafficStatsHostReachable' \
|| fail "acceptance: the admin backend accepts any Traffic Stats host silently"
log_step "Acceptance: the systemd state has three values, not two"
# util.Exec выбрасывает вывод при ненулевом коде, а `systemctl is-active`
# отвечает словом состояния в stdout ВМЕСТЕ с кодом 3. Пока другого
# примитива не было, «служба остановлена» и «спросить не удалось» приходили
# в панель одним значением.
code_has apps/util/linux.go -F -- 'func ExecProbe' \
|| fail "acceptance: the exit-code aware probe is missing"
code_has apps/service/hysteria2.go -F -- 'HysteriaServiceUnknown' \
|| fail "acceptance: the unknown service state is missing"
! code_has apps/service/hysteria2.go -F -- 'is-active --quiet' \
|| fail "acceptance: --quiet suppresses the very output the probe needs"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
const start = source.indexOf("func Hysteria2Online");
if (start < 0) throw new Error("Hysteria2Online is missing");
const rest = source.slice(start + 1);
const end = rest.indexOf("\nfunc ");
const body = (end < 0 ? rest : rest.slice(0, end))
.split("\n")
.filter((line) => !/^\s*\/\//.test(line))
.join("\n");
// Ярлык «служба неактивна -> пусто, ошибки нет» делал недоступность
// Traffic Stats API неотличимой от «никто не подключён»: сборщик метрик
// выставлял apiReachable = true, ни разу не сходив в API.
if (/hysteria2IsRunning\(\)|HysteriaServiceState\(\)/.test(body)) {
throw new Error("the display path still derives the online picture from systemd");
}
if (/return\s+map\[string\]int64\{\}\s*,\s*nil/.test(body)) {
throw new Error("the display path still invents an empty online picture");
}
' || fail "acceptance: the online picture must come from the Traffic Stats API"
code_has apps/model/vo/dashboard.go -E -- 'ServiceState +string' \
|| fail "acceptance: the dashboard no longer reports the service state separately"
code_has apps/model/vo/peer.go -F -- 'PeerOnlineStateUnavailable' \
|| fail "acceptance: the peer list cannot report an unknown online state"
! code_has apps/service/peer.go -E -- 'onlineUsers,\s*_\s*:=' \
|| fail "acceptance: the peer list swallows the Traffic Stats failure again"
log_step "Acceptance: the Hysteria journal is parsed in its actual format"
# JSON-логгер Hysteria 2.12.2 пишет `time` числом (EpochMillisTimeEncoder,
# причём дробным), поэтому разбор в структуру со строковым полем падал на
# КАЖДОЙ строке и уходил в fallback: `HYSTERIA_LOG_FORMAT=json` был включён,
# а структурой никто не пользовался.
code_has apps/service/journal.go -F -- 'func hysteriaLogTime' \
|| fail "acceptance: the journal no longer understands the upstream time format"
code_has apps/service/journal.go -F -- 'func hysteriaLogContext' \
|| fail "acceptance: the structured context of the journal is dropped"
! code_has apps/service/journal.go -E -- 'json\.Unmarshal\(\[\]byte\(.*\), &parsed\)' \
|| fail "acceptance: the journal record is parsed straight into the display type again"
log_step "Acceptance: production does not run the upstream update check"
# Версией Hysteria владеет versions.env -> сборка -> пакет -> оркестратор.
# Параллельная проверка обновлений ничего не обновляет, ходит наружу при
# каждом старте и отличает production от тестового окружения, где она уже
# выключена.
grep -q '^Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1$' package/systemd/hysteria-server.service \
|| fail "acceptance: the hysteria unit does not disable the upstream update check"
log_step "Acceptance: the config screen shows the file, not a rendering of it"
# Панель накладывала ответ сервера на полный объект дефолтов, поэтому
# отсутствующая секция trafficStats показывалась как `:9999`. Экран,
# существующий ради диагностики дрейфа, этот дрейф скрывал.
code_has apps/service/hysteria2_profile.go -F -- 'func BuildHysteria2Profile' \
|| fail "acceptance: the production-profile projection is missing"
code_has apps/controller/config.go -F -- 'service.BuildHysteria2Profile()' \
|| fail "acceptance: the config route no longer returns the profile projection"
! code_has apps/controller/config.go -F -- 'service.GetHysteria2Config()' \
|| fail "acceptance: the config route serializes the internal model to the browser again"
# Списки секций профиля в Go и в оркестраторе обязаны совпадать: два
# источника истины разъехались бы молча, и панель начала бы называть
# расхождением то, что оркестратор считает нормой.
"$BUN_BIN" -e '
const fs = require("node:fs");
const go = fs.readFileSync("apps/service/hysteria2_profile.go", "utf8");
const ts = fs.readFileSync("orchestrator/src/steps/configAssertions.ts", "utf8");
const goBlock = go.match(/var hysteria2ProfileSections = \[\]string\{([\s\S]*?)\}/);
if (!goBlock) throw new Error("the Go profile section list is missing");
const goSections = [...goBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort();
const tsBlock = ts.match(/const allowed = new Set\(\[([\s\S]*?)\]\)/);
if (!tsBlock) throw new Error("the orchestrator profile section list is missing");
const tsSections = [...tsBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
// В оркестраторе режимы TLS взаимоисключающие, панель показывает оба.
const expected = [...new Set([...tsSections, "acme", "tls"])].sort();
if (goSections.join(",") !== expected.join(",")) {
throw new Error(
"profile sections diverged:\n go: " + goSections.join(",") + "\n ts: " + expected.join(",")
);
}
' || fail "acceptance: the profile section lists must not diverge"
log_step "Acceptance: the read-only config view carries no secrets"
# Скачиваемая выгрузка того же конфига секреты вырезает; читающий экран не
# имеет права быть щедрее. Пароль обфускации, токены ACME DNS и учётные
# данные outbound-прокси уезжали в браузер в открытом виде.
# Выравнивание полей структуры делает gofmt, поэтому число пробелов между
# именем и типом задавать нельзя.
code_has apps/model/vo/hysteria2_profile.go -E -- 'PasswordSet +bool' \
|| fail "acceptance: the profile view still carries the obfuscation password"
code_has apps/model/vo/hysteria2_profile.go -E -- 'SecretSet +bool' \
|| fail "acceptance: the profile view still carries the Traffic Stats secret"
! code_has apps/model/vo/hysteria2_profile.go -E -- '(Password|Secret) +\*?string' \
|| fail "acceptance: the profile view declares a secret-bearing field"
code_has apps/service/hysteria2_profile.go -F -- 'sanitizeURLValue(' \
|| fail "acceptance: the auth url reaches the browser with its machine token"
log_step "Acceptance: the config sanitizer follows YAML aliases"
# redactNode и redactSubtree разбирали документ, последовательность,
# отображение и скаляр, но не AliasNode: секрет за якорем покидал сервер
# дважды — и по ссылке, и в самом объявлении якоря.
code_has apps/service/hysteria2_export.go -F -- 'yaml.AliasNode' \
|| fail "acceptance: the config sanitizer ignores YAML aliases"
code_has apps/service/hysteria2_export.go -F -- 'func newAliasGuard' \
|| fail "acceptance: the alias walk has no cycle guard"
log_step "Acceptance: the removed lifecycle leftovers stay removed"
# Мёртвые остатки прежней архитектуры: лексикографическое сравнение версий
# без потребителя, пустая заглушка освобождения Hysteria и второй канал
# доставки QR-кода, который панель рисует сама.
[ ! -f apps/util/string.go ] \
|| fail "acceptance: the unused lexicographic CompareVersion is back"
local dead
for dead in CompareVersion ReleaseHysteria2; do
local carriers
carriers="$(code_mentions_in "${dead}(" \
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
[ -z "$carriers" ] \
|| fail "acceptance: the removed ${dead} is referenced again: $carriers"
done
! code_has apps/model/vo/peer.go -F -- 'QrCode' \
|| fail "acceptance: the deprecated QR transport is back in the peer response"
}
# Наблюдаемость: журнал админки и страницы, которые его показывают.
+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. Оператор не имел причин