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
+95
View File
@@ -637,6 +637,101 @@ wildcard-маршрутом фронтенда или дублирующая р
- неизвестные поля и файл не с расширением `.json` отклоняются;
- корректный одиночный документ доходит до базы и создаёт пира.
## A10a. Отзыв учётных данных и сходимость сессий (unit)
`apps/service/peer_secret_rotation_test.go` — контракт «новое поколение
credentials получает новую идентичность сессий». Проверяется поведение, а не
наличие поля:
- **сессия, установленная по отозванному секрету ПОСЛЕ успешного `/kick`,
завершается очередным циклом учёта.** Сценарий воспроизводится буквально:
авторизация по старому секрету удерживается внутри `GET /online`, за это время
выполняется полная ротация с `/kick` → 200, затем авторизация отпускается и
возвращает старый `authId` — то есть соединение регистрируется уже после
разрыва. Ни одна операция при этом не отказала; сходимость даёт то, что старое
поколение стало orphan;
- то же после `/kick` → 500;
- повторная отправка **того же** секрета рвёт сессию (повтор отзыва), но
идентичность не меняет: `secret_digest` тот же;
- секрет из одних пробелов не меняет ничего — ни digest, ни `auth_id`, ни
сессии; прежде правило было записано двумя разными условиями, и такой секрет
записывался бы в базу, не разрывая сессий;
- импорт: новый секрет при **прежнем** `authId` в файле и при совпадении по
**имени** ротирует идентичность; свой новый `authId` из файла не подменяется;
повторный импорт того же файла идентичность не трогает;
- `newPeerAuthID` выдаёт идентификатор той же формы, что и создание пира, и
проходит собственную проверку продукта (`peerAuthIDPattern`).
`apps/service/cron_test.go` дополнительно доказывает, что старое поколение
уходит в `/kick` именно как сессия без строки в базе.
## A10b. Правдивая диагностика (unit)
`apps/service/hysteria2_state_test.go` — матрица двух независимых источников:
| systemd | Traffic Stats API | что обязано быть показано |
| --- | --- | --- |
| `active` | отвечает | служба работает, API доступен, картина подключений реальная |
| `inactive` | молчит | оба факта согласованы |
| **`unknown`** | **отвечает** | «состояние неизвестно» + API доступен + картина подключений реальная |
| `active` | отказывает | служба работает, API недоступен, состояние данных — `error` |
Третья строка — главная регрессия: прежний путь показывал здесь «служба
остановлена» и «API доступен» одновременно, причём второе — не сходив в API.
Отдельно проверяется разбор ответа `systemctl is-active`: `active`, `inactive`,
`failed`, `activating`, `deactivating`, пробелы, многострочный вывод, пустой
ответ и **незнакомое слово** — последнее означает `unknown`, а не «остановлена».
`apps/service/peer_access_test.go``PagePeer` отвечает
`onlineState: unavailable` и не теряет список пиров, когда Traffic Stats API
недоступен; при доступном API признак `ok`, а `online`/`onlineDevices` в строках
отражают фактический ответ.
`apps/util/exec_probe_test.go``ExecProbe` отличает ненулевой код возврата
(ответ команды) от невозможности запустить процесс. Требует рабочего `bash`,
поэтому вне Linux пропускается.
## A10c. Журнал Hysteria в фактическом формате upstream (unit)
`apps/service/journal_test.go` — записи собираются так же, как их пишет zap с
`EncoderConfig` upstream:
- числовое `time` (epoch millis, **дробное**`EpochMillisTimeEncoder` делит
наносекунды на миллисекунду) разбирается; прежний разбор падал на каждой такой
строке и показывал оператору сырой JSON;
- структурный контекст (`addr`, `id`, `error`, `listen`, `tx`, …) сохраняется и
дописывается к сообщению в устойчивом (алфавитном) порядке;
- числа печатаются без экспоненты, вложенные объекты — компактным JSON в одну
строку;
- секреты вырезаются и из сообщения, и из контекста, а адрес остаётся читаемым;
- не-JSON строка и JSON без `msg` не теряются;
- запись без собственных `level`/`time` добирает их из journald;
- `MESSAGE`, отданный journald **массивом байт** (сообщение не является
корректным UTF-8), больше не выбрасывает всю запись.
## A10d. Проекция конфига на production-профиль (unit)
`apps/service/hysteria2_profile_test.go`:
- канонический конфиг оркестратора читается целиком и расхождений не даёт;
- отсутствующая секция остаётся отсутствующей — в частности, `trafficStats` не
превращается в выдуманный `:9999`;
- явное `false` отличается от «не задано»;
- секции вне профиля перечисляются поимённо и по порядку, включая **неизвестные
HY2XS** — они считаются по сырому YAML, а не по типизированной модели;
- ответ, сериализованный **так, как его получит браузер**, не содержит ни
пароля обфускации, ни секрета Traffic Stats API, ни machine token, ни учётных
данных outbound; при этом диагностические факты сохранены («пароль задан»,
адрес auth-URL, имена параметров ACME DNS);
- отсутствующий файл конфига — отказ, а не пустой профиль.
`apps/service/hysteria2_export_test.go` дополнен якорями YAML: секрет за
`&anchor`/`*alias` вырезается и по ссылке, и в самом объявлении; URL с учётными
данными за якорем — тоже; ссылка на составной узел редактируется целиком;
рекурсивная ссылка (alias на предка) не зацикливает санитайзер и не отказывает —
`yaml.v3` строит на ней действительно циклический граф узлов.
## A7. Контракт версий (build)
Шаг `verify_versions_contract` (`tools/build/lib/versions.sh`) роняет сборку до