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:
+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-якоря: санитайзер выгрузки следует
|
||||
по ссылкам
|
||||
|
||||
Reference in New Issue
Block a user