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
+1 -1
View File
@@ -78,7 +78,7 @@
### Панель
- [admin/04-admin-panel.md](admin/04-admin-panel.md) — HY2XS admin
- [admin/15-ui-contracts.md](admin/15-ui-contracts.md) — контракты панели: иконки, структурированные ошибки, необязательные поля, атрибуция
- [admin/15-ui-contracts.md](admin/15-ui-contracts.md) — контракты панели: иконки, структурированные ошибки, необязательные поля, отсутствие выдуманного состояния, атрибуция
### Эксплуатация
@@ -1,5 +1,14 @@
# Разбор кода перед сборкой `1.0.0-rc3`
```text
Findings base: 6d1686b8 — дерево, на котором найдены дефекты
Fixes verified in: 8dcb50a — дерево, на котором проверены исправления
```
Две базы названы отдельно намеренно: сам разбор шёл по первой, а выводы
исправлений проверялись по второй, и без этой пары читателю приходилось
гадать, к какому состоянию относится каждое утверждение отчёта.
Источник — не прогон на хосте, а разбор дерева на коммите `6d1686b8` и повторная
сверка Hysteria-интеграции с официальной документацией Hysteria 2. Разбор
проводился по состоянию **после** предыдущего прохода
@@ -0,0 +1,373 @@
# Разбор кода перед сборкой `1.0.0-rc4`
```text
Findings base: 8dcb50a — дерево, на котором найдены дефекты
Fixes verified in: рабочее дерево этого прохода
```
Источник — разбор дерева после [прохода
rc3](2026-09-02-v1.0.0-rc3-preflight-findings.md) и сверка Hysteria-интеграции с
исходниками тега `app/v2.12.2`, а не только с документацией: часть выводов
зависит от того, что именно делает upstream-код, а не от того, как это описано.
Тема прохода — **граница между компонентами**. Предыдущие два прохода привели в
порядок внутреннюю логику отзыва доступа: единое правило доступа, правильный
порядок «запись → разрыв», повторяемость операций и сходимость через цикл учёта.
Этот проход занимается местами, где HY2XS соприкасается с Hysteria и с
оператором: идентичностью сессий, адресом control plane, форматом журнала и тем,
что панель показывает как факт.
## Сводка
| ID | Дефект | Приоритет | Статус |
| --- | --- | --- | --- |
| ROT-01 | Ротация секрета сохраняла идентичность сессий, и отзыв не сходился | P1 | закрыт |
| ADM-HY2-01 | Адрес Traffic Stats API имел два несовместимых контракта | P1 | закрыт |
| ADM-HY2-02 | JSON-журнал Hysteria 2.12.2 не разбирался ни одной строкой | P2 | закрыт |
| ADM-HY2-03 | «Служба остановлена», «неизвестно» и доступность API были одним значением | P2 | закрыт |
| ADM-HY2-04 | В production работала upstream-проверка обновлений | P2-low | закрыт |
| ADM-HY2-05 | Страница конфигурации показывала дефолты UI вместо файла | P2 | закрыт |
| ADM-HY2-06 | Санитайзер выгрузки не следовал по YAML-якорям | P2 | закрыт |
| ADM-HY2-07 | Читающий экран отдавал в браузер больше секретов, чем выгрузка | P3 | закрыт |
| ADM-HY2-08 | Мёртвые остатки прежней архитектуры | P4 | закрыт |
| GATE-03 | Гейт освобождения admission-замка проверял форму, а не замок | P3 | закрыт |
| DOC-03 | Отчёт rc3 не называл, к какому дереву относятся выводы | P3 | закрыт |
ROT-01 и ADM-HY2-01…08 пришли внешним разбором; уточнения ниже — при проверке
его выводов по коду и исходникам upstream. Три из них меняли предложенное
решение, а не только формулировку, и отмечены как **уточнение**.
---
## ROT-01 — ротация секрета сохраняла идентичность сессий
**Наблюдалось рассуждением и воспроизведено тестом.** `go test -race` здесь
зелёный и был зелёным: гонка логическая, работа с памятью в ней не участвует.
Отзыв секрета состоит из двух шагов — записать новый `secret_digest` и завершить
сессии, установленные по старому. Второй шаг умеет не удаться, и это заложено в
архитектуру: сходимость обязан обеспечить цикл учёта. Но сверять ему было нечем.
```text
до: secret S1 -> authId A
после: secret S2 -> authId A
```
Сессия в `/online` называется просто `A`. Пир `A` в базе существует, доступ ему
открыт, устройств не больше разрешённого — по всем признакам это действующая
сессия нового состояния. Признака «установлена по уже отозванному секрету» в
системе не существовало.
**Уточнение к внешнему разбору.** Дефект не ограничивался формой панели. Импорт
— вторая дверь к смене учётных данных, и через неё проходил тот же случай:
запись найдена по имени либо несёт прежний `auth_id`, а секрет в файле новый.
`applyPeerImportEntry` писал `auth_id` только тогда, когда он задан в файле,
поэтому идентичность оставалась прежней.
**Путь без единой неудачи.** Помимо неудавшегося `/kick` существует сценарий, в
котором все операции успешны. Hysteria дожидается ответа backend-auth и только
после `ok = true` помечает соединение аутентифицированным и сообщает о нём
Traffic Stats API (`app/v2.12.2`):
```text
1. клиент с S1 начинает авторизацию, Hysteria2Auth ждёт ответа GET /online
2. оператор меняет секрет: запись прошла, /kick вернул 200
3. задержанная авторизация возвращает ALLOW со СТАРЫМ authId
4. Hysteria регистрирует сессию — уже после kick'а
```
Атомарной пары «решение авторизации + регистрация онлайна» upstream API не даёт,
поэтому повторным чтением базы перед ответом окно не закрыть: оно сдвинется, но
останется.
**Как закрыто.** Поколение учётных данных и идентичность сессий связаны:
```text
peer.id — постоянная идентичность записи
secret — учётные данные
auth_id — идентичность поколения живых сессий
```
Новый секрет получает новый `auth_id`; `/kick` идёт по старому. Пережившая
сессия называется значением, которого в базе больше нет, и цикл учёта видит её
как orphan — механизмом, который уже существует. Ротация происходит **тогда и
только тогда**, когда меняется `secret_digest`: повторная отправка того же
секрета остаётся повторной попыткой отзыва, но нового поколения не создаёт.
Ни отдельной таблицы отозванных поколений, ни очереди повторов, ни
распределённых блокировок не заведено.
**Цена названа прямо.** До следующего цикла учёта такая сессия считается сессией
неизвестного пира, поэтому её дельта трафика приписывается некому и попадает в
потери цикла. Это не более 30 секунд трафика одного пира на одну ротацию.
Колонка «прежний `auth_id`» ради этих секунд ввела бы второй идентификатор
сессии — ровно то состояние, из-за которого отзыв и не сходился.
**Чем закреплено.** `apps/service/peer_secret_rotation_test.go`: удержание
in-flight авторизации внутри `/online` с успешным `/kick` посередине,
`/kick` → 500, повтор того же секрета, секрет из одних пробелов, четыре
сценария импорта. Плюс гейт приёмки на форму записи в обеих дверях.
---
## ADM-HY2-01 — два контракта одного адреса
`normalizeIpv4Host` принимал любой корректный IPv4, шаблон честно рендерил
`trafficStats.listen: <адрес>:36712`, а `assertHysteriaConfigMatchesProfile`
сверял установленный конфиг с тем же значением. Все гейты проходили.
Вторая половина продукта имеет другой контракт: `GetHysteria2ApiPort` берёт из
`trafficStats.listen` **только порт**, а `proxy.NewHysteria2Api` всегда строит
`http://127.0.0.1:<port>`.
```text
HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=192.168.1.10
Hysteria слушает 192.168.1.10:36712
админка идёт 127.0.0.1:36712
/online недоступен → лимит устройств fail-closed → отказ авторизации ВСЕМ пирам
учёт трафика и принудительное отключение не работают
```
То есть валидная с точки зрения всех проверок конфигурация выключала продукт.
**Как закрыто.** Адрес зафиксирован: `validateRuntimeConfig` принимает только
`127.0.0.1`. Traffic Stats API — внутренний control plane одного процесса на
одной машине, сценария с другим адресом у него нет.
**Уточнение к внешнему разбору.** Одной половины мало. Файл может разойтись с
оркестратором правкой руками, поэтому вторая половина фикса — в админке:
`parseTrafficStatsPort` больше не отбрасывает хост молча, а называет
расхождение. Wildcard и loopback принимаются (обмен через них состоится), любой
другой адрес — отказ с указанием на `reconfigure`. Молчаливая подстановка
loopback вместо прочитанного значения и есть тот самый второй контракт.
**Чем закреплено.** `orchestrator/test/env.test.ts` (`0.0.0.0`, `127.0.0.2`,
LAN, публичный адрес), `apps/service/config_traffic_stats_test.go`, гейты
приёмки на обе половины и на `package/config/hy2xs.env`.
---
## ADM-HY2-02 — JSON-журнал не разбирался ни одной строкой
Юнит запускает Hysteria с `HYSTERIA_LOG_FORMAT=json`. Разбор складывал запись
прямым `json.Unmarshal` в `vo.LogHysteria2Vo`, у которого `Time string`.
**Уточнение к внешнему разбору.** Предложенная замена поля на `int64` не
работает. JSON-логгер `app/v2.12.2` объявлен так:
```go
TimeKey: "time", LevelKey: "level", MessageKey: "msg",
EncodeTime: zapcore.EpochMillisTimeEncoder
```
а `EpochMillisTimeEncoder` печатает `float64` — наносекунды, делённые на
миллисекунду, то есть **дробное** число вида `1788321234567.1235`. На `int64`
разбор падал бы так же, как на `string`.
Каждая строка уходила в fallback, и панель показывала сырой JSON: структурный
журнал был включён, а структурой никто не пользовался.
**Как закрыто.** Разбор идёт через `map[string]any`: известные ключи заполняют
колонки, остальные (`addr`, `id`, `error`, `listen`, `tx`, …) дописываются к
сообщению как `msg [key=value …]` в алфавитном порядке и проходят тот же
санитайз. `time` принимается числом и строкой; при отсутствии берётся
`__REALTIME_TIMESTAMP` journald.
**Найдено сверх разбора.** `journalctl -o json` отдаёт `MESSAGE` **массивом
байт**, если сообщение не является корректным UTF-8. Прежний `Message string`
ронял разбор всей строки, и она молча выпадала из журнала — то есть именно те
записи, ради которых журнал чаще всего и открывают.
**Чем закреплено.** `apps/service/journal_test.go` — записи собираются тем же
способом, каким их пишет zap.
---
## ADM-HY2-03 — «остановлена», «неизвестно» и доступность API были одним значением
```text
Hysteria остановлена
Traffic Stats API доступен
онлайн: 0
```
Три утверждения об одной системе, первые два несовместимы, и все три получены из
одного ответа `systemctl`: общий `Hysteria2Online` при неактивной службе отдавал
пустую карту **без ошибки**, поэтому сборщик метрик выставлял
`apiReachable = true`, ни разу не обратившись к API, а список пиров показывал
всех офлайн.
**Уточнение к внешнему разбору.** Развязать флаги было недостаточно: состояние
службы физически нечем было прочитать. `util.Exec` выбрасывает вывод команды,
как только код возврата не нулевой, а `systemctl is-active` отвечает словом
состояния в stdout **вместе** с кодом 3.
**Как закрыто.** Добавлен `util.ExecProbe`, для которого ненулевой код — ответ,
а не отказ. Состояние службы стало трёхзначным (`active` / `inactive` /
`unknown`), `Hysteria2Online` спрашивает Traffic Stats API напрямую и возвращает
ошибку, а решает, как её показать, вызывающий: дашборд — отдельными фактами,
список пиров — признаком `onlineState: unavailable` и «онлайн неизвестен» вместо
«офлайн». Незнакомое слово в ответе systemd означает `unknown`, а не
«остановлена».
**Чем закреплено.** `apps/service/hysteria2_state_test.go` (матрица 2×2),
разбор ответа `is-active`, `PagePeer` в обоих состояниях,
`apps/util/exec_probe_test.go`.
---
## ADM-HY2-04 — upstream-проверка обновлений в production
`app/v2.12.2` по умолчанию проверяет обновления после старта. В сборочном и e2e
окружении HY2XS она отключена, а в `hysteria-server.service` — нет.
Security-дефекта здесь нет: Hysteria бинарник не заменяет. Дефект
архитектурный — у версии обязан быть один владелец
(`versions.env` → сборка → пакет → оркестратор), а production не имеет права
отличаться от тестового окружения.
**Как закрыто.** `Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1` в юните + гейт
приёмки.
---
## ADM-HY2-05 — экран показывал дефолты UI вместо файла
Панель накладывала ответ сервера на полный объект значений по умолчанию, поэтому
отвечала не на тот вопрос:
| в файле | показывалось |
| --- | --- |
| секции `trafficStats` нет | `listen: :9999` |
| `speedTest: false` | вкладка спрятана как «не задано» |
| `ignoreClientBandwidth: true` без `bandwidth` | не показано вовсе |
| `masquerade.string.statusCode` (200..599) | переключатель |
| ни `tls`, ни `acme` | дефолты ACME |
Экран, существующий ради диагностики расхождений, эти расхождения скрывал.
**Как закрыто — сокращением, а не развитием.** Ответ описывает
production-профиль: значения так, как они записаны (`null` = «не задано»), и
отдельный список секций вне профиля, считаемый по сырому YAML — секция, которую
типизированная модель не понимает, обязана быть замечена, а не потеряна. Списки
секций в Go и в оркестраторе сверяются гейтом приёмки.
Удалены три редактора, которые ничего не сохраняли (outbounds, список значений,
словарь), вместе с их компонентами и view-моделью на `DeepRequired`: пока
«универсальный редактор Hysteria» существует в дереве, он отрастает заново.
Полный документ по-прежнему доступен санитизированной выгрузкой.
---
## ADM-HY2-06 — санитайзер не следовал по YAML-якорям
`redactNode` и `redactSubtree` разбирали документ, последовательность,
отображение и скаляр, но не `yaml.AliasNode`.
**Уточнение к внешнему разбору.** Утечек две, а не одна:
```yaml
shared: &credential VERY_SECRET_VALUE
obfs:
salamander:
password: *credential
```
Значение под `password` — ссылка, и `redactSubtree` на ней был no-op. Само
объявление якоря стоит под ключом `shared`, секретоподобным не выглядящим, — и
его не трогал никто. Секрет уезжал в выгрузку дважды.
**Как закрыто.** Обход идёт по цели ссылки: редакция цели закрывает оба
вхождения сразу. Защита от циклов обязательна — `yaml.v3` на ссылке, указывающей
на предка, строит действительно циклический граф узлов (проверено), и без неё
обход не завершился бы.
**Чем закреплено.** Четыре регрессии в `hysteria2_export_test.go`: скаляр за
якорем, URL с учётными данными, составной узел, рекурсивная ссылка.
---
## ADM-HY2-07 — читающий экран был щедрее выгрузки
`auth` и `trafficStats.secret` были закрыты `json:"-"`, а пароль обфускации,
токены ACME DNS, учётные данные outbound-прокси и `masquerade.proxy.url` — нет.
Скачиваемая выгрузка того же конфига их вырезает.
Привилегий это не повышало — маршрут под admin JWT, — но read-only экрану эти
значения не нужны. Закрыто вместе с ADM-HY2-05: вместо значения показывается
диагностический факт («задан» / «не задан», имена параметров без значений,
адрес auth-URL с вырезанным токеном). Проверяется сериализацией ответа целиком,
а не перечислением полей: новое поле без `json:"-"` иначе не заметил бы никто.
---
## ADM-HY2-08 — мёртвые остатки
- `util.CompareVersion` — лексикографическое сравнение версий без единого
потребителя (`2.10` < `2.9`); удалён вместе с файлом;
- `service.ReleaseHysteria2``return nil`, вызывавшийся при завершении
сервиса; остаток модели, в которой панель считала Hysteria своим подпроцессом;
- `PeerClientConfigVo.QrCode` — второй канал доставки QR, который панель рисует
сама из ссылки;
- компонент `UnitSelect` и три функции `utils/byte.ts` — без потребителей.
---
## GATE-03 — гейт проверял форму, а не замок
Освобождение admission-замка проверялось регулярным выражением
`/defer\s+\w+\(\)/`, то есть «в функции есть какой-нибудь отложенный вызов».
Такой гейт пережил бы
```go
unlockAdmission := lockPeerAdmission(...)
defer someOtherCleanup()
```
— замок, который не отпускается никогда. Теперь имя переменной берётся из самого
присваивания, поэтому проверяется освобождение **именно этого** замка, а
переименование переменной гейт не ломает.
---
## Что проверено независимо
Исходники `app/v2.12.2` — по трём вопросам, от которых зависели решения:
порядок `Authenticate``authenticated = true``LogOnlineState`; точный
`EncoderConfig` JSON-логгера (включая тип, который даёт `EpochMillisTimeEncoder`);
переменная `HYSTERIA_DISABLE_UPDATE_CHECK`.
Поведение `yaml.v3` на рекурсивных якорях проверено экспериментом, а не
предположением: библиотека строит циклический граф узлов и умеет его же
сериализовать обратно.
## Что остаётся релизным гейтом
```bash
go test -race ./service/... -count=1
```
на Debian-билдере. Локально `-race` недоступен (`CGO_ENABLED=0`, компилятора C
нет), и это ограничение среды, а не результат. Обе гонки, найденные в этом и
предыдущем проходах, — логические: детектор гонок на них молчит принципиально,
поэтому закрыты они детерминированными тестами с удержанием ответа `/online`.
## Ручные проверки на живом сервере
1. сменить секрет пира при активном подключении; убедиться, что клиент со старым
секретом теряет доступ не позднее 30 секунд, а `authId` в списке изменился;
2. то же при недоступном Traffic Stats API на момент сохранения (частичный
результат) — сходимость обязана произойти после его восстановления;
3. подменить `trafficStats.listen` на LAN-адрес: админка обязана назвать
расхождение, а страница конфигурации — пометить адрес;
4. остановить `hysteria-server`: дашборд показывает «остановлена» и «API
недоступен», список пиров — «онлайн неизвестен»;
5. сломать доступ к `systemctl` при живой Hysteria: дашборд показывает
«состояние неизвестно» и **доступный** API;
6. открыть страницу журнала Hysteria: записи разобраны, контекст виден,
секретов нет;
7. добавить в конфиг секцию вне профиля (например `resolver`) и открыть страницу
конфигурации: секция обязана попасть в «расхождение конфигурации».
+1
View File
@@ -42,3 +42,4 @@
| --- | --- | --- |
| 2026-09-01 | коммит `c0a43ae9`, сверка с Hysteria 2 и Element Plus | [UX-06…UX-10, LOG-01…LOG-05, AUTH-01/02, CORE-01/02, TYPE-01](2026-09-01-v1.0.0-rc2-preflight-findings.md) |
| 2026-09-02 | коммит `6d1686b8`, повторная сверка с Hysteria 2 | [ADMIT-04/05, RECON-01/02, GATE-02, DOC-02, VER-01](2026-09-02-v1.0.0-rc3-preflight-findings.md) |
| 2026-09-02 | коммит `8dcb50a`, сверка с исходниками `app/v2.12.2` | [ROT-01, ADM-HY2-01…08, GATE-03, DOC-03](2026-09-02-v1.0.0-rc4-preflight-findings.md) |
+228 -58
View File
@@ -360,8 +360,8 @@ AES-GCM. Значение без этого префикса — не «форм
| Слой | Назначение | Поведение при неизвестных полях |
| --- | --- | --- |
| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
| Сырой YAML | экспорт и сохранение | неизвестные поля **сохраняются** |
| Типизированная модель | значения профиля для панели и генерация клиентских ссылок | неизвестные поля не отображаются |
| Сырой YAML | экспорт, а также перечень секций верхнего уровня | неизвестные поля **сохраняются** |
Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
@@ -371,69 +371,114 @@ AES-GCM. Значение без этого префикса — не «форм
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
### Третий слой: модель отображения
### Третий слой: проекция на production-профиль
У типизированной модели есть подслой, о котором стоит сказать отдельно, потому
что он определяет, как устроены шаблоны страницы Hysteria.
`Hysteria2ServerConfig` описывает то, что **приходит по сети**, и почти все его
секции необязательны — ровно так же, как в upstream YAML. Форма же обращается к
ним напрямую: `dataForm.tls.cert`, `dataForm.acme.dns.config`,
`dataForm.resolver.https.sni`.
Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` — и он прав: обращение
через возможно отсутствующий объект падает в рантайме. Спасало то, что форма
строится merge'ем поверх полного объекта значений по умолчанию, то есть
инвариант «секция есть всегда» существовал, но держался на порядке присваиваний
внутри компонента и нигде не был выражен типом.
Закрыто одним преобразованием на границе, а не 141 оператором `?.` и не
`as any`:
Панель показывает **то, что записано в файле**, — и это отдельный слой, а не
типизированная модель целиком.
```text
ответ API (Hysteria2ServerConfig, секции необязательны)
/etc/hysteria/config.yaml
normalizeHysteriaViewModel()
типизированная модель (значения) + сырой YAML (ключи верхнего уровня)
Hysteria2ServerConfigView — все секции обязательны
BuildHysteria2Profile()
шаблон
Hysteria2ProfileVo: секции профиля + список расхождений
страница (только чтение)
```
`Hysteria2ServerConfigView` выводится из `Hysteria2ServerConfig` типом, а не
пишется вторым списком полей. Поэтому новая секция в схеме ломает компиляцию на
объекте значений по умолчанию — то есть поле upstream нельзя молча не
отобразить.
**Что было.** Ответ отдавал внутреннюю модель серверного конфига целиком, а
frontend накладывал его на полный объект значений по умолчанию
(`DeepRequired` + merge). В результате экран отвечал не на тот вопрос:
Побочное следствие: `v-if` в шаблоне перестали проверять присутствие секции и
проверяют только то, что действительно определяет выбор ветки. Например для
обфускации это `dataForm.obfs.type === 'gecko'` вместо
`dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko` — вторая половина
дублировала первую и существовала только из-за необязательности типа.
```text
вопрос, который решает оператор:
что реально написано в /etc/hysteria/config.yaml?
Этот слой не участвует в экспорте: выгрузка идёт от исходного YAML и сохраняет
неизвестные поля, поэтому их потеря в модели отображения безвредна.
вопрос, на который отвечал экран:
как выглядел бы конфиг, если недостающие куски заполнить дефолтами UI?
```
Разница не косметическая:
| в файле | показывалось | чем это плохо |
| --- | --- | --- |
| секции `trafficStats` нет | `listen: :9999` | скрыта причина отказа всего контура доступа |
| `speedTest: false` | вкладка спрятана как «не задано» | явное значение выдано за отсутствие |
| `disableUDP: false` | то же | то же |
| `ignoreClientBandwidth: true` без блока `bandwidth` | не показано вовсе | опция влияет на сервер и невидима |
| `masquerade.string.statusCode` (200..599) | переключатель | тип не соответствует upstream |
| ни `tls`, ни `acme` | дефолты ACME | выдуманная конфигурация выпуска сертификата |
То есть экран, существующий ради диагностики расхождений, эти расхождения
скрывал.
**Что показывается теперь.** Секции production-профиля — те, которыми
действительно управляет оркестратор:
```text
listen · auth · tls|acme · obfs · bandwidth · ignoreClientBandwidth
congestion · quic · trafficStats
```
Отсутствие секции остаётся отсутствием: `null` означает «в файле этого нет», и
подменять его дефолтом нельзя — `false`, `0` и пустая строка являются законными
значениями и должны быть от него отличимы.
Всё остальное попадает в **расхождение конфигурации** — список секций верхнего
уровня вне профиля (`masquerade`, `resolver`, `sniff`, `acl`, `outbounds`,
`mimic`, `realm`, а также любая секция, о которой HY2XS ещё не знает). Он
считается по сырому YAML, а не по типизированной модели: секция, которую модель
не понимает, обязана быть замечена именно как расхождение, а не потеряна при
разборе. Список секций профиля в Go и whitelist оркестратора сверяются гейтом
приёмки — два источника истины разъехались бы молча.
**Почему не универсальный редактор Hysteria.** Продуктом является один профиль:
конфиг генерирует оркестратор и сам же проверяет соответствие установленного
файла профилю (`assertHysteriaConfigMatchesProfile`). Панель конфиг не пишет —
маршрутов записи в API нет. Достраивать её до редактора всех возможностей
upstream значит поддерживать вторую, никем не применяемую модель продукта. Для
полного документа есть санитизированная выгрузка, которая сохраняет и
неизвестные поля.
### Читающий экран не щедрее выгрузки
Прежний ответ вёз в браузер секреты. `auth` и `trafficStats.secret` были закрыты
`json:"-"`, а пароль обфускации, токены ACME DNS (`acme.dns.config`), учётные
данные outbound-прокси и `masquerade.proxy.url` — нет. Скачиваемый экспорт того
же конфига их вырезает; привилегий это не повышало (маршрут под admin JWT), но
read-only экрану эти значения не нужны вовсе.
Теперь вместо значения показывается диагностически достаточный факт:
| поле | что видно оператору |
| --- | --- |
| `obfs.*.password` | «задан» / «не задан»; сам пароль выдаётся в клиентской ссылке пира |
| `trafficStats.secret` | «задан» / «не задан» |
| `acme.dns.config` | имена параметров без значений |
| `auth.http.url` | адрес с вырезанным `access_token` (тем же санитайзером, что и выгрузка) |
### Страница Hysteria — только чтение, и теперь это верно на всех уровнях
Страница отрисована с `:disabled="true"` и прямо сообщает, что конфигом владеет
`hy2xs-orchestrator reconfigure`. Маршрутов записи серверного конфига в API нет
— они удалены вместе с мёртвым updater/config-write слоем.
Страница прямо сообщает, что конфигом владеет `hy2xs-orchestrator reconfigure`.
Маршрутов записи серверного конфига в API нет — они удалены вместе с мёртвым
updater/config-write слоем.
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
передаются в них как `:outbounds=`, `:tags=`, `:map-object=` — без `v-model`,
то есть у их событий `update:*` нет ни одного слушателя. Оператор мог добавить
outbound, увидеть его в списке и уйти в уверенности, что изменил конфигурацию
сервера; изменения не переживали даже переключения вкладки.
передавались в них без `v-model`, то есть у их событий `update:*` не было ни
одного слушателя. Оператор мог добавить outbound, увидеть его в списке и уйти в
уверенности, что изменил конфигурацию сервера; изменения не переживали даже
переключения вкладки. У одного из них цена была ещё и измеримой: редактор списка
значений работал на `vuedraggable`, которая тянула в bundle полную сборку Vue с
рантайм-компилятором шаблонов — около полумегабайта ради перетаскивания тегов в
недоступной для редактирования форме.
Все три приведены к отображению. У одного из них цена была ещё и измеримой:
редактор списка значений работал на `vuedraggable`, которая поставляется
UMD-сборкой, поэтому её `require("vue")` разрешался в полную сборку Vue вместе с
рантайм-компилятором шаблонов — около полумегабайта в bundle ради
перетаскивания тегов в недоступной для редактирования форме.
Сначала все три были приведены к отображению, а вместе с переходом на проекцию
профиля удалены целиком вместе со своими компонентами: пока «универсальный
редактор» существует в дереве, он отрастает заново.
### Санитайз экспорта
@@ -620,6 +665,62 @@ backend.
| **удаление пира** | сессия пира, по запомненному `authId` |
| **импорт партии** | сессии всех существующих пиров партии, по СТАРЫМ `authId` |
#### Смена секрета меняет и идентичность сессий
**Контракт:**
```text
peer.id — постоянная идентичность записи
secret — учётные данные
auth_id — идентичность ПОКОЛЕНИЯ живых Hysteria-сессий
```
Новый секрет получает новый `auth_id`; `/kick` при этом идёт по **старому**
именно им Hysteria знает отзываемую сессию. Правило действует на обеих дверях к
смене учётных данных: и в форме панели, и в импорте.
Ротация происходит тогда и только тогда, когда меняется `secret_digest`.
Повторная отправка того же секрета — это повторная попытка отзыва (она рвёт
сессию снова, как и повторное «Отключить»), но нового поколения credentials не
создаёт, поэтому идентичность сессий не трогает.
**Зачем это нужно.** Отзыв секрета состоит из двух шагов, и второй умеет не
удаться — сходимость обязан обеспечить cron. Но пока `auth_id` оставался
прежним, сверять было нечем: сессия, установленная по отозванному секрету,
называлась тем же значением, пир в базе существовал, доступ был открыт,
устройств не больше разрешённого. Признака «установлена по уже недействительным
учётным данным» в системе не существовало вовсе.
Хуже того, у этого состояния есть путь **без единой неудачи**. Ответ авторизации
и регистрация соединения в Traffic Stats API — не одна транзакция: Hysteria
сначала дожидается `Authenticate`, и только после `ok = true` помечает
соединение аутентифицированным и сообщает о нём Traffic Stats API. Значит:
```text
1. клиент со старым секретом начинает авторизацию,
Hysteria2Auth читает пира и ждёт ответа GET /online
2. оператор меняет секрет: запись прошла, /kick вернул 200
3. задержанная авторизация возвращает ALLOW со СТАРЫМ authId
4. Hysteria регистрирует сессию — уже после kick'а
```
Все шаги успешны, а сессия по отозванному секрету жива. Атомарной пары «решение
авторизации + регистрация онлайна» upstream API не даёт, поэтому повторным
чтением базы перед ответом это окно не закрыть — оно сдвинется, но останется.
Ротация `auth_id` закрывает оба случая одним уже существующим механизмом:
пережившая сессия называется значением, которого в базе больше нет, и очередной
цикл учёта видит её как orphan (см. «Сверка живых сессий»). Ни отдельной таблицы
отозванных поколений, ни очереди повторов для этого не заводится.
**Цена названа прямо:** до следующего цикла учёта такая сессия считается сессией
неизвестного пира, поэтому её дельта трафика приписывается некому и попадает в
потери цикла. Это не более 30 секунд трафика одного пира на одну ротацию —
осознанный размен, о котором см. «Учёт трафика — операционная граница, а не
биллинг». Колонка «прежний `auth_id`» ради этих секунд ввела бы второй
идентификатор сессии, то есть ровно то состояние, из-за которого отзыв и не
сходился.
Правило асимметрично намеренно: **ограничение применяется немедленно,
послабление — нет.** Увеличенная квота, продлённый срок, поднятый лимит
устройств, правка имени или пометки сессию не рвут — у оператора нет причины
@@ -706,10 +807,13 @@ disabled = 1 работает: условие смотрит на ЗАПР
квота / срок работает: cron видит их через peerAccessDenied
maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва больше не будет
импорт old→new НЕ работает: в базе уже new, повтор разорвёт ЕГО
смена секрета НЕ работает: в базе уже новый digest, старая сессия
неотличима от законной
```
Две нижние строки не имели механизма схождения вовсе, и обе закрывает cron
см. «Сверка живых сессий» ниже. Отдельной таблицы retry, очереди отложенных
Три нижние строки не имели механизма схождения вовсе. Первые две закрывает cron
см. «Сверка живых сессий» ниже; третью — ротация `auth_id`, после которой она
сводится к первым двум: старое поколение становится orphan. Отдельной таблицы retry, очереди отложенных
операций и хранимого «списка того, что не удалось разорвать» для этого не
нужно: `/online` и есть список живых сессий.
@@ -729,11 +833,11 @@ maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва бол
готовые `authId`. Включение пира не сбрасывает `banned_until`, а снятие
блокировки не включает отключённого пира.
**Состояние службы по systemd в этом пути не участвует.** `util.Exec`
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
удалось» в одну ошибку, поэтому `Hysteria2IsRunning` не является основанием ни
для отказа операции, ни для её пропуска — ни здесь, ни в cron. Ответ даёт само
обращение к Traffic Stats API.
**Состояние службы по systemd в этом пути не участвует.** Оно годится только для
отображения и не является основанием ни для отказа операции, ни для её пропуска
— ни здесь, ни в cron. Ответ даёт само обращение к Traffic Stats API. Как
именно читается состояние службы и почему у него три значения, а не два — см.
«Состояние службы и доступность API — разные факты».
### Цикл учёта принадлежит планировщику
@@ -779,7 +883,9 @@ Cron обходит **каждый `authId`, который Hysteria счита
для каждого authId из GET /online:
выборка пиров не удалась → не рвать НИЧЕГО (цикл прекращается)
строки в базе нет → /kick (пир удалён либо переподписан)
строки в базе нет → /kick (пир удалён, переподписан импортом
либо это отозванное поколение
учётных данных)
peerAccessDenied → /kick (disabled / квота / срок / блокировка)
maxDevices непригоден → /kick (повреждённая граница — не «безлимит»)
устройств > maxDevices → /kick (лимит снижен, сессии остались)
@@ -827,9 +933,52 @@ Hysteria, значит она жива, а её Traffic Stats API слушает
затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация:
доступность Traffic Stats API входит в install/doctor smoke.
Путь ОТОБРАЖЕНИЯ остаётся терпимым: дашборд и признак `online` в списке пиров
показывают пустую картину, когда служба остановлена, — это честный ответ на
вопрос «кто сейчас на связи».
### Состояние службы и доступность API — разные факты
Путь отображения тоже строгий, и это исправление, а не ужесточение ради
симметрии.
**Что было.** Общий `Hysteria2Online` начинался с ярлыка «служба неактивна по
мнению systemd → пустая карта, ошибки нет». Пустая карта БЕЗ ошибки неотличима
от «никто не подключён», поэтому сборщик метрик выставлял `apiReachable = true`,
ни разу не обратившись к Traffic Stats API, а список пиров показывал всех
офлайн. Дашборд умел утверждать одновременно:
```text
Hysteria остановлена
Traffic Stats API доступен
онлайн: 0
```
— три утверждения об одной системе, из которых первые два несовместимы, и все
три получены из одного ответа `systemctl`.
**Источник неопределённости.** `util.Exec` выбрасывает вывод команды, как только
код возврата не нулевой, а `systemctl is-active` отвечает словом состояния в
stdout ВМЕСТЕ с кодом 3. Прочитать это слово было нечем, поэтому «служба
неактивна» и «спросить не получилось» приходили в панель одним значением
`false`.
**Как теперь.** Два источника отвечают на два вопроса, и ни один не выводится из
другого:
| источник | значения |
| --- | --- |
| `systemctl is-active` через `util.ExecProbe` | `active` · `inactive` · `unknown` |
| фактическое обращение к Traffic Stats API | доступен · недоступен |
`unknown` — это не «остановлена». Дашборд показывает для него предупреждение
«состояние службы неизвестно», а не критическую плашку «служба остановлена»: у
этих двух состояний разные действия оператора, и второе отправляло его
перезапускать работающий туннель.
Список пиров при недоступном API отвечает `onlineState: unavailable` — один
признак на страницу, а не nullable-флаг в каждой строке, — и показывает «онлайн
неизвестен» вместо «офлайн». Число подключённых устройств в этом состоянии
показывается как `?`: ноль был бы утверждением, которого никто не проверял.
Решения о доступе на этих значениях по-прежнему не строятся: авторизация и cron
спрашивают Traffic Stats API напрямую.
#### Лимит выдерживает параллельные подключения
@@ -1126,3 +1275,24 @@ Compatibility-ветка пережила слой совместимости,
26. неудавшийся разрыв не оставляет систему в несогласованном состоянии
навсегда: сессия удалённого либо переподписанного пира и превышение
`maxDevices` устраняются очередным циклом учёта
27. смена секрета меняет `auth_id`, поэтому сессия, установленная по отозванным
учётным данным, становится orphan и завершается очередным циклом учёта — в
том числе когда `/kick` прошёл успешно, но соединение зарегистрировалось
после него
28. `auth_id` генерируется ровно одним способом (`newPeerAuthID`), и ротация
происходит тогда и только тогда, когда меняется `secret_digest`
29. адрес Traffic Stats API — внутренний контракт: оркестратор допускает только
`127.0.0.1`, а админка называет расхождение вместо молчаливой подстановки
loopback
30. состояние службы по systemd имеет три значения, и `unknown` не выдаётся за
«остановлена»; доступность Traffic Stats API — независимый факт, полученный
фактическим обращением
31. отказ Traffic Stats API отображается как «состояние неизвестно», а не как
«все пиры офлайн»
32. журнал Hysteria разбирается в фактическом формате upstream (числовое `time`)
и сохраняет структурный контекст записи
33. страница конфигурации показывает записанные значения без синтетических
дефолтов, отдельно перечисляет секции вне production-профиля и не отдаёт
браузеру секретов
34. секреты не покидают сервер и через YAML-якоря: санитайзер выгрузки следует
по ссылкам
+45 -1
View File
@@ -209,7 +209,51 @@ ElMessageBox.confirm(...)` без разбора отказа оставляет
---
## 8. Атрибуция
## 8. Панель не выдумывает состояние
**Правило.** Отсутствие данных показывается как отсутствие данных.
Нарушений было три, и все три давали оператору ответ, противоположный истине.
**Страница конфигурации** строила форму merge'ем ответа сервера поверх полного
объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
показывалась как `listen: :9999`, а явное `speedTest: false` считалось
ненастроенным и прятало свою вкладку. Экран, существующий ради диагностики
дрейфа, этот дрейф скрывал. Теперь ответ отличает «не задано» (`null`) от
значения, а секции вне production-профиля перечисляются отдельным списком
расхождений.
**Список пиров** получал `onlineUsers, _ := Hysteria2Online()` и при любом сбое
control plane показывал всех пиров офлайн. Теперь страница несёт
`onlineState: ok | unavailable` — один признак на ответ, а не флаг в каждой
строке, — и при `unavailable` показывает «онлайн неизвестен», а число устройств
как `?`.
**Дашборд** выводил доступность Traffic Stats API из ответа `systemctl` и умел
утверждать «служба остановлена» и «API доступен» одновременно. Теперь это два
независимых факта, а у состояния службы три значения: `active`, `inactive`,
`unknown`.
**Запрещено:**
* подставлять значение по умолчанию вместо отсутствующего в ответе;
* показывать `0`, `false` или «офлайн» там, где данные не получены;
* выводить один факт из другого, если их можно спросить по отдельности.
**Секреты на читающем экране.** Read-only страница не имеет права быть щедрее
санитизированной выгрузки того же документа. Вместо значения показывается
диагностический факт: «задан» / «не задан» для паролей и секретов, имена
параметров без значений для `acme.dns.config`, адрес с вырезанным
`access_token` для auth-URL.
**Редакторы без сохранения запрещены.** Конфигом владеет оркестратор, маршрутов
записи в API нет, поэтому поля ввода на странице конфигурации обещают действие,
которого не существует. Проверяется контрактным тестом: в шаблоне нет ни
`el-input`, ни `el-switch`, ни `el-select`, ни `v-model`.
---
## 9. Атрибуция
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
+6
View File
@@ -246,3 +246,9 @@ HY2XS обязан **понимать** современную схему Hyster
10. публичные клиентские endpoint/URL берутся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen`/request-host
11. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации и пароль, и совместимый клиент подключается по ней напрямую
12. SNI в ссылке берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется
13. `trafficStats.listen` слушает loopback: Traffic Stats API — внутренний
control plane, и админка обращается к нему только по `127.0.0.1`. Любой
другой адрес разводит компоненты по разным адресам и выключает лимит
устройств, учёт трафика и принудительное отключение разом
14. Hysteria не проверяет обновления сама (`HYSTERIA_DISABLE_UPDATE_CHECK=1`):
версией владеет `versions.env` -> сборка -> пакет -> оркестратор
@@ -347,6 +347,56 @@ ClientAliveCountMax 2
- auth material
- что используется совместимый клиентский конфиг
### Ни один пир не проходит авторизацию
Симптом резкий: подключения перестают устанавливаться у всех сразу, в журнале
админки — `device limit unavailable`.
Лимит устройств проверяется **fail-closed**: без ответа `/online` админка не
знает, сколько устройств уже на связи, и пускать подключения не имеет права.
Значит вопрос ровно один — почему недоступен Traffic Stats API.
```bash
# 1. что записано в конфиге
grep -A2 '^trafficStats:' /etc/hysteria/config.yaml
# 2. отвечает ли API по этому адресу
curl -sS -H "Authorization: <trafficStatsSecret>" http://127.0.0.1:36712/online
# 3. что говорит сама админка
journalctl -u hy2xs-admin -n 100 --no-pager | grep -i 'traffic stats'
```
Частая причина — правка `trafficStats.listen` руками. Админка обращается к
Traffic Stats API **только по loopback**, поэтому адрес вроде `192.168.1.10`
разводит Hysteria и панель по разным адресам: сама Hysteria работает, туннели
существующих клиентов живут, но лимит устройств, учёт трафика и принудительное
отключение выключаются разом. С таким конфигом админка отказывает явно:
```text
trafficStats.listen слушает 192.168.1.10, а админка обращается к Traffic Stats
API только по loopback. ...Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`
```
Страница конфигурации показывает тот же адрес и помечает его как не-loopback.
### Дашборд показывает «состояние службы неизвестно»
Это **не** «Hysteria остановлена». Значение означает, что не удалось получить
ответ `systemctl is-active hysteria-server`: сломанный или недоступный systemctl
при живой Hysteria выглядит именно так.
Проверяется отдельно от туннеля:
```bash
systemctl is-active hysteria-server # active | inactive | failed | ...
```
Доступность Traffic Stats API на дашборде — независимый факт, полученный
фактическим обращением к API. Сочетание «состояние неизвестно» + «API доступен»
означает исправно работающий туннель и сломанную диагностику службы; сочетание
«служба активна» + «API недоступен» — предыдущий раздел.
### Install/reconfigure падает на DNS AAAA
Проверить значение `HY2XS_DNS_AAAA_POLICY` в `/etc/hy2xs/hy2xs.env`:
- `strict` (default): AAAA приводит к fail в IPv4-only профиле;
+24
View File
@@ -207,6 +207,30 @@ hy2xs-orchestrator reconfigure --package-dir /usr/local/lib/hy2xs/package --conf
- `HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` — install-only bootstrap поля.
- изменение значений в `/etc/hy2xs/hy2xs.env` после install не выполняет автоматическую ротацию существующих credentials.
## 10a. Отзыв учётных данных пира
Смена секрета в панели — операция отзыва, и она выполняется целиком:
```text
1. новый secret_digest и новый auth_id записываются одной операцией
2. POST /kick по СТАРОМУ auth_id
3. если разрыв не удался либо соединение зарегистрировалось уже после него —
старая сессия становится orphan и завершается очередным циклом учёта
```
Что это значит для оператора:
- гарантия отзыва — **не позднее 30 секунд** (интервал цикла учёта), а не «до
переподключения клиента по своей воле»;
- частичный результат (`peer_disconnect_failed`) означает лишь то, что первая
попытка разрыва не удалась: повторять операцию не требуется, состояние сойдётся
само;
- идентификатор пира в списке (`authId`) после смены секрета меняется — это
идентичность поколения сессий, а не постоянный идентификатор записи;
- трафик старой сессии за эти секунды не приписывается пиру и попадает в потери
цикла учёта (запись уровня `error` в журнале админки). Для операционной
границы доступа это допустимо; биллингом учёт трафика в `1.0.0` не является.
## 11. IPv4/IPv6 policy
- HY2XS работает в IPv4-only режиме.
+14
View File
@@ -44,6 +44,20 @@
- `ReadWritePaths=/var/lib/hysteria`
- `CapabilityBoundingSet=CAP_NET_BIND_SERVICE`
Окружение `hysteria-server.service` фиксировано тремя переменными:
| переменная | значение | зачем |
| --- | --- | --- |
| `HYSTERIA_LOG_LEVEL` | `info` | уровень журнала |
| `HYSTERIA_LOG_FORMAT` | `json` | структурный журнал; админка разбирает именно его формат — `time` числом epoch millis |
| `HYSTERIA_DISABLE_UPDATE_CHECK` | `1` | версией Hysteria владеет `versions.env` -> сборка -> пакет -> оркестратор |
Проверка обновлений выключена не из соображений безопасности — Hysteria сама
бинарник не заменяет, — а потому что у версии обязан быть один владелец. Иначе
служба ходит наружу при каждом старте и печатает в журнал советы, которые к этой
установке не относятся. В тестовом окружении продукта она и так отключена, и
production не имеет права отличаться.
Важно:
- HY2XS admin не должен запускаться как часть unit Hysteria
- unit-файлы не должны быть склеены
+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`) роняет сборку до
+6
View File
@@ -46,6 +46,12 @@
26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей
27. живая сессия, которой в базе больше ничего не соответствует (пир удалён либо его `auth_id` заменён импортом, а разрыв в тот момент не удался), завершается очередным циклом учёта — не позднее 30 секунд
28. превышение `maxDevices` живыми сессиями устраняется тем же циклом: после неудавшегося разрыва при снижении лимита повтор формы даёт успех без `/kick`, и единственный механизм схождения здесь — cron
29. смена секрета пира меняет его `auth_id`: клиент со старым секретом теряет доступ не позднее 30 секунд даже в том случае, когда `/kick` прошёл успешно, а соединение зарегистрировалось после него
30. `trafficStats.listen` слушает `127.0.0.1`; конфиг с не-loopback адресом админка отвергает с явным сообщением, а не молча ходит на loopback
31. `hysteria-server.service` запущен с `HYSTERIA_DISABLE_UPDATE_CHECK=1`: внешних запросов проверки версии при старте нет
32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd
33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON
34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов
## C1. Семантический smoke конфига
+5
View File
@@ -23,6 +23,11 @@
17. неизвестное значение `HY2XS_PUBLIC_ENDPOINT_POLICY`
18. импорт пиров с невалидной записью — файл не применяется частично
19. импорт пиров, пытающийся перезаписать `bootstrap-admin-peer`
20. `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` не равен `127.0.0.1` — install/reconfigure
отказывают: `0.0.0.0`, другой адрес loopback, адрес LAN, публичный адрес
21. `trafficStats.listen` в уже установленном `/etc/hysteria/config.yaml`
указывает на не-loopback адрес — админка отказывает с сообщением, называющим
адрес и способ починки, а не молча обращается к `127.0.0.1`
## E. Fix20 production matrix (обязательные сценарии)