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:
+1
-1
@@ -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`) и открыть страницу
|
||||
конфигурации: секция обязана попасть в «расхождение конфигурации».
|
||||
@@ -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
@@ -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-якоря: санитайзер выгрузки следует
|
||||
по ссылкам
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 профиле;
|
||||
|
||||
@@ -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 режиме.
|
||||
|
||||
@@ -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-файлы не должны быть склеены
|
||||
|
||||
@@ -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`) роняет сборку до
|
||||
|
||||
@@ -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 конфига
|
||||
|
||||
|
||||
@@ -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 (обязательные сценарии)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user