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:
@@ -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`) роняет сборку до
|
||||
|
||||
Reference in New Issue
Block a user