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