cb20d8d28f
Отзыв секрета не сходился: `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
1299 lines
98 KiB
Markdown
1299 lines
98 KiB
Markdown
# Admin panel: HY2XS admin
|
||
|
||
> Контракты панели, закреплённые тестами и релизными гейтами — отрисовка
|
||
> иконок, структурированные ошибки, необязательные поля и генерация секретов,
|
||
> атрибуция — вынесены в отдельный документ:
|
||
> [15-ui-contracts.md](15-ui-contracts.md).
|
||
|
||
## Цель документа
|
||
|
||
Зафиксировать модель работы с admin-панелью: HY2XS admin является **штатным компонентом HY2XS**, а не внешней зависимостью, которую target server где-то добывает во время установки.
|
||
|
||
## Место компонента в системе
|
||
|
||
Проект состоит из компонентов двух разных типов:
|
||
|
||
- **Hysteria2** — external runtime dependency. Ванильный upstream-бинарник, который оркестратор забирает из официального источника во время установки.
|
||
- **HY2XS admin** — native HY2XS component. Исходный код лежит в репозитории, компонент собирается production builder'ом вместе с остальными артефактами HY2XS.
|
||
|
||
Это противопоставление и есть основная архитектурная граница.
|
||
|
||
## Состав компонента
|
||
|
||
- исходный код admin-компонента хранится в [`apps/`](../apps);
|
||
- backend реализован на Go;
|
||
- frontend реализован на Vue/Vite;
|
||
- frontend-ассеты встраиваются в Go-бинарник;
|
||
- компонент собирается production builder'ом вместе с остальными артефактами HY2XS;
|
||
- готовый бинарник едет в install package как `ui/hy2xs-admin/hy2xs-admin`.
|
||
|
||
## Правила поставки
|
||
|
||
HY2XS admin:
|
||
|
||
- поставляется внутри итогового пакета;
|
||
- имеет свой install dir;
|
||
- имеет свой data dir;
|
||
- запускается отдельным rootless systemd unit (`hy2xs-admin`);
|
||
- не требует target-side build.
|
||
|
||
Target server **не собирает** admin-компонент из исходного кода и **не скачивает** его из внешнего репозитория.
|
||
|
||
## Scope панели
|
||
|
||
Панель нужна для:
|
||
|
||
- operator-facing управления;
|
||
- просмотра статуса;
|
||
- работы с пользователями / трафиком / сущностями доступа;
|
||
- удобной админской рутины.
|
||
|
||
Панель не должна:
|
||
|
||
- определять install lifecycle сервера;
|
||
- превращать систему в сложный control plane;
|
||
- диктовать scope оркестратора.
|
||
|
||
HY2XS admin работает как надстройка над Hysteria YAML/API-слоем. Это нормально: важно только, чтобы источник истины по runtime-состоянию был понятен и не было двух конкурирующих конфигурационных миров без правил синхронизации.
|
||
|
||
Относительно конфигурации Hysteria панель **read-only**: конфиг генерирует оркестратор.
|
||
|
||
## Сетевая идентичность панели принадлежит оркестратору
|
||
|
||
Панель не конфигурирует себя сама.
|
||
|
||
| Величина | Источник |
|
||
| --- | --- |
|
||
| Порт панели | `HY2XS_UI_PORT` → `ExecStart … -p <port>` |
|
||
| Адрес привязки | `HY2XS_UI_BIND_HOST` из `/etc/hy2xs/hy2xs.env` |
|
||
| Каталог данных | `HY2XS_DATA_DIR` |
|
||
| Каталог логов | `HY2XS_LOG_DIR` |
|
||
| Маршрут панели | всегда `/` |
|
||
| TLS | терминируется снаружи (SSH-туннель или reverse proxy) |
|
||
|
||
До v1 эти величины дублировались в таблице `config` собственными ключами
|
||
панели: оркестратор передавал порт аргументом, панель записывала его в SQLite и
|
||
тут же читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база
|
||
при этом не добавляла — это был второй источник истины без содержания.
|
||
|
||
В v1 таких ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного
|
||
TLS-слоя у панели тоже нет: production-контракт — `HY2XS_UI_BIND_HOST=127.0.0.1`
|
||
и `HY2XS_UI_PUBLIC_ACCESS=false`, то есть внутренний сервис. Если панели
|
||
когда-нибудь понадобится публичный endpoint, TLS обязан заканчиваться на
|
||
ingress/reverse-proxy, а не возвращаться к модели «панель публикует себя сама».
|
||
|
||
Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках
|
||
их словаря нет. Всё, что нужно для распознавания и удаления старой установки, —
|
||
в [14-legacy-cleanup.md](../operations/14-legacy-cleanup.md).
|
||
|
||
## Пространства имён HTTP API
|
||
|
||
| Префикс | Назначение | Middleware |
|
||
| --- | --- | --- |
|
||
| `/healthz` | liveness/readiness | нет |
|
||
| `/internal/hysteria/auth` | machine-to-machine: Hysteria спрашивает разрешение на подключение пира | `LocalOnly` + `MachineAuth` |
|
||
| `/api/...` | операторский и auth API | rate limiter, JWT, admin |
|
||
|
||
Разделение отражает разницу в природе маршрутов. `/internal/hysteria/auth` —
|
||
не интерфейс для человека и не часть операторского API: это внутренний
|
||
IPC-подобный HTTP endpoint между двумя процессами на одной машине. До v1 он
|
||
лежал под тем же префиксом, что и JWT-защищённый админский API, хотя
|
||
middleware у них не пересекаются.
|
||
|
||
Путь machine-auth — **runtime-контракт продукта**: он записывается в
|
||
`/etc/hysteria/config.yaml` и в `post-install.env`. Поэтому он объявлен ровно
|
||
в двух местах — `constant.HysteriaMachineAuthPath` в админке и
|
||
`HYSTERIA_MACHINE_AUTH_PATH` в оркестраторе, — а сборка сверяет их между собой
|
||
и с шаблонами.
|
||
|
||
## Журнал запросов не содержит значений query-параметров
|
||
|
||
Hysteria обращается к машинному endpoint'у как
|
||
`/internal/hysteria/auth?access_token=<machine token>` — при каждом подключении
|
||
пира. Поэтому в журнале админки пишется **путь**, а не `RequestURI`:
|
||
|
||
```json
|
||
{ "msg": "POST /internal/hysteria/auth → 200 (2 ms)",
|
||
"reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
|
||
```
|
||
|
||
Поле `msg` собирается из тех же величин, что уже лежат в структурных полях, и
|
||
не добавляет к ним ничего: запись остаётся машиночитаемой, а сообщение
|
||
существует, чтобы человек мог прочитать строку журнала, не собирая её из шести
|
||
колонок. Раньше `entry.Info()` вызывался без аргумента, и logrus записывал
|
||
`"msg":""` для каждого запроса — страница системных логов показывала оператору
|
||
пустой столбец, точно отражая содержимое файла.
|
||
|
||
Читаемость сообщения не является лазейкой для query-строки: в `msg` попадает
|
||
только путь, и это закреплено тестом, который проверяет обе половины сразу —
|
||
сообщение непустое И не несёт ни токена, ни знака `?`.
|
||
|
||
Пока логировался `RequestURI`, действующий machine token оседал открытым
|
||
текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через
|
||
`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в
|
||
штатном режиме работы — мимо всей структурной редакции, сделанной для конфигов
|
||
и env.
|
||
|
||
Значения query-параметров не логируются вовсе: список «что можно» пришлось бы
|
||
вести вручную, и он неизбежно разошёлся бы с набором маршрутов. Имена
|
||
параметров сохранены — для диагностики их достаточно.
|
||
|
||
Каналов журналирования у панели ровно один. Админка запускается через
|
||
`gin.New()` + `gin.Recovery()`, а не `gin.Default()`: штатный `gin.Logger()`
|
||
печатает путь **вместе с query string** в stdout, откуда он уходит в journald, а
|
||
оттуда — в diagnostics-бандл. Это был второй, независимый канал той же утечки, и
|
||
починка собственного логгера его бы не закрыла.
|
||
|
||
Журнал Hysteria (`ExportLog`, вкладка логов) проходит через санитайз
|
||
`service.SanitizeLogText`: `HY2_AUTH_URL` несёт `access_token`, и upstream волен
|
||
упомянуть его в сообщении об ошибке обращения к auth-backend. Санитайз
|
||
сохраняет host, port и path — диагностика от него не страдает. Тот же проход
|
||
применяется к `journal-*.log` внутри diagnostics-бандла оркестратора.
|
||
|
||
**Собственный журнал админки проходит тот же санитайз.** Раньше не проходил: он
|
||
отдавался сырым файлом через `c.File(constant.SystemLogPath)` и показывался во
|
||
вкладке без обработки. Асимметрия «чужому журналу не доверяем, своему доверяем»
|
||
ничем не обоснована — файл в обоих случаях покидает сервер и пересылается в
|
||
переписке, — и цена у неё была известна поимённо: пока bootstrap-пароль
|
||
администратора печатался в лог warning'ом, обычная кнопка выгрузки отдавала его
|
||
открытым текстом. Сам warning убран (см. ниже), но защита стоит и на выходе:
|
||
следующий неосторожный `logrus.Warnf("token=%s", …)` не превратит выгрузку
|
||
журнала в канал утечки.
|
||
|
||
## Импорт и экспорт
|
||
|
||
| Операция | Статус |
|
||
| --- | --- |
|
||
| Экспорт пиров (`POST /api/peer-export`) | есть, в двух режимах |
|
||
| Импорт пиров (`POST /api/peer-import`) | есть |
|
||
| Экспорт конфига Hysteria (`POST /api/config/exportHysteria2Config`) | есть, с вырезанием секретов |
|
||
| Экспорт/импорт таблицы `config` | **удалён** |
|
||
|
||
Generic-выгрузка таблицы `config` отдавала её целиком, исключая только сырой
|
||
Hysteria YAML. В той же таблице лежат `JWT_SECRET`, `PEER_SECRET_KEY`,
|
||
`PEER_SECRET_ENCRYPTION_KEY` и `HYSTERIA2_TRAFFIC_STATS_SECRET`: кнопка
|
||
«Export» выгружала их в открытом виде, а зеркальный импорт позволял их
|
||
подменить. Для `PEER_SECRET_ENCRYPTION_KEY` это не только вопрос секретности —
|
||
после подмены перестают расшифровываться секреты уже существующих пиров.
|
||
|
||
Осмысленного production-сценария у этой пары не было: конфигурацией сервера
|
||
владеет оркестратор, перенос пиров делают `peer-import`/`peer-export`.
|
||
|
||
### Точечный доступ к таблице `config` — по allowlist
|
||
|
||
Удаления generic-пары оказалось недостаточно. Опасность осталась в точечном
|
||
API: `getConfig` и `listConfig` принимали произвольный ключ, а проверка записи
|
||
работала denylist'ом из трёх ключей оркестратора. То есть авторизованный запрос
|
||
`?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов пиров,
|
||
а `updateConfigs` позволял подменить `JWT_SECRET` и оба peer-ключа. Отверстие
|
||
сменило размер, но не исчезло.
|
||
|
||
В v1:
|
||
|
||
| Ключ | Чтение | Запись |
|
||
| --- | --- | --- |
|
||
| `RESET_TRAFFIC_CRON` | да | да |
|
||
| `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор |
|
||
| `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет |
|
||
| любой другой | нет | нет |
|
||
|
||
Список — **allowlist**, и это структурное решение, а не стилистическое.
|
||
Denylist требует, чтобы автор каждого нового ключа вспомнил про этот файл:
|
||
забытый ключ при denylist сразу публичен, при allowlist — сразу закрыт. Отказ
|
||
по умолчанию не зависит от внимательности.
|
||
|
||
Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не
|
||
было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь.
|
||
|
||
### Мёртвое состояние в таблице `config` удалено
|
||
|
||
Четыре ключа предыдущего поколения к v1 перестали чем-либо управлять и удалены
|
||
вместе со строками в базе (миграция `006_drop_dead_config_keys`):
|
||
|
||
| Ключ | Что с ним было не так |
|
||
| --- | --- |
|
||
| `HYSTERIA2_ENABLE` | жизненным циклом Hysteria владеет systemd; единственным потребителем ключа была строка в журнале |
|
||
| `HYSTERIA2_CONFIG` | второй источник истины рядом с `/etc/hysteria/config.yaml`, причём читался **первым**: значение, попавшее в базу в обход продукта, молча становилось тем, что панель показывает и из чего генерирует ссылки |
|
||
| `HYSTERIA2_TRAFFIC_TIME` | настройка «период учёта трафика» без единого потребителя в рантайме: интервал сбора метрик задан в коде |
|
||
| `HYSTERIA2_CONFIG_REMARK` | пустая read-only строка, которую никто никогда не записывал |
|
||
|
||
Настоящую замену получил только последний: имя профиля в клиентской ссылке
|
||
теперь выводится из **имени пира**, а при его отсутствии — из публичного хоста.
|
||
Это то различие, которое пользователю и нужно видеть в списке серверов, и оно не
|
||
требует ни одной дополнительной настройки.
|
||
|
||
После очистки панель владеет ровно одной настройкой — `RESET_TRAFFIC_CRON`, — а
|
||
всё остальное в таблице является внутренними секретами.
|
||
|
||
### Расписание сброса трафика: планировщик принадлежит процессу
|
||
|
||
Смена `RESET_TRAFFIC_CRON` **не перезапускает** HTTP-сервер.
|
||
|
||
Раньше перезапускала: обработчик вызывал `StopServer()`, точка входа крутила
|
||
`for { runServer() }` и поднимала сервис заново, чтобы новый планировщик прочитал
|
||
настройку из базы. При этом `InitCron()` на каждом вызове создавал новый
|
||
`cron.New()` и нигде не сохранял ссылку, а `cron.Stop()` не вызывался нигде.
|
||
Итог: каждая правка расписания добавляла **целый дублирующий набор джоб**, а
|
||
старое расписание сброса продолжало работать. После двух правок на процессе
|
||
висели три планировщика и три разных расписания одновременно.
|
||
|
||
Теперь фиксированные джобы регистрируются один раз за жизнь процесса, а
|
||
расписание сброса переносится на месте по своему `EntryID`.
|
||
|
||
Выражение проверяется **до записи в базу** тем же парсером
|
||
(`cron.ParseStandard`), которым его потом разбирает планировщик. Невалидное
|
||
значение — отказ `4xx`, база не меняется. Раньше строка сохранялась, API отвечал
|
||
успехом, а сброс трафика молча исчезал до следующего чтения журнала.
|
||
|
||
Пустое значение легально и означает «автоматический сброс выключен».
|
||
|
||
Сервис завершается штатно по `SIGTERM`: сначала останавливается планировщик и
|
||
дожидаются запущенные джобы, затем закрывается SQLite. Обратный порядок означал
|
||
бы работу джоб с уже закрытым соединением при каждом `systemctl restart`.
|
||
|
||
Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, —
|
||
«этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет»,
|
||
и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
|
||
|
||
Оба оставшихся экспорта формируются **в памяти** и отдаются прямо в ответ.
|
||
Раньше они шли через `os.Create` в `/var/lib/hy2xs-admin/export/`, и файл там
|
||
оставался навсегда — при `?includeSecrets=true` это означало расшифрованные
|
||
секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога
|
||
`export/` больше не существует.
|
||
|
||
### Партия настроек применяется целиком или не применяется вовсе
|
||
|
||
`POST /api/config/updateConfigs` выполняется в три прохода:
|
||
|
||
1. проверка партии целиком — права на ключ, дубликаты ключей, значения;
|
||
2. одна транзакция базы;
|
||
3. применение к рантайму.
|
||
|
||
Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал.
|
||
Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на
|
||
втором — оператор получал отказ на запрос, который систему уже изменил.
|
||
|
||
Тест на этот случай существовал, но ставил запрещённый ключ **первым** и не
|
||
смотрел в базу — поймать частичное применение он был неспособен по построению.
|
||
Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы
|
||
проверяется явно: `TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden`.
|
||
|
||
### Экспорт пиров: два режима, а не флаг
|
||
|
||
| Кнопка | Запрос | Что внутри |
|
||
| --- | --- | --- |
|
||
| **Экспорт настроек** | `POST /api/peer-export` | список пиров без секретов |
|
||
| **Резервная копия** | `POST /api/peer-export?includeSecrets=true` | то же плюс действующие секреты подключения |
|
||
|
||
Разница здесь продуктовая, а не техническая, и её нельзя оставлять неявной.
|
||
Записи с пустым секретом при импорте получают **новые** секреты. То есть
|
||
перенос обычным экспортом восстанавливает пиров, их квоты, лимиты и счётчики —
|
||
но все существующие клиентские ссылки после него перестают работать.
|
||
|
||
Раньше кнопка в панели была одна и всегда звала маршрут без `includeSecrets`,
|
||
хотя документация называла эту пару механизмом переноса пиров. Оператор
|
||
переносил пиров и обнаруживал, что все клиенты отвалились.
|
||
|
||
Резервная копия содержит фактические учётные данные доступа к VPN в открытом
|
||
виде, поэтому запускается только через явное подтверждение с описанием риска.
|
||
Такой файл следует хранить как пароль и удалять после завершения переноса.
|
||
|
||
#### Резервная копия либо полная, либо её нет
|
||
|
||
Для `includeSecrets=true` правило строгое: если секрет хотя бы одного пира
|
||
получить не удалось — расшифровка не прошла или шифртекста нет вовсе — **весь**
|
||
запрос завершается ошибкой, называющей проблемного пира, и файл не создаётся.
|
||
|
||
Раньше оба этих случая обрабатывались молча: пир уезжал в файл с пустым полем
|
||
`secret`, а запрос отвечал успехом. Оператор получал файл, выглядящий полным:
|
||
|
||
```json
|
||
[{"name":"A","secret":"..."},
|
||
{"name":"B","secret":""},
|
||
{"name":"C","secret":"..."}]
|
||
```
|
||
|
||
Обнаруживалось это уже после импорта на новом сервере: B получал новый
|
||
сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима ровно в
|
||
том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
|
||
частичный результат под этим именем — худший из возможных ответов.
|
||
|
||
Безопасная выгрузка (`includeSecrets=false`) шифртекст не трогает вовсе и
|
||
повреждённых данных не замечает: пустой `secret` там — не потеря, а весь смысл
|
||
режима.
|
||
|
||
Секреты пиров хранятся только зашифрованными, в единственном формате `v1:` +
|
||
AES-GCM. Значение без этого префикса — не «формат предыдущего поколения», а
|
||
повреждённые данные, и расшифровка на них отказывает. Прежняя реализация
|
||
возвращала такое содержимое как якобы успешно расшифрованный секрет, то есть
|
||
мусор из колонки уходил и в клиентскую ссылку, и в резервную копию.
|
||
|
||
### Импорт пиров
|
||
|
||
Импорт проверяется так же строго, как обычное создание пира: те же правила для
|
||
имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно:
|
||
|
||
- неизвестные поля в JSON отклоняются, а не игнорируются молча;
|
||
- файл обязан содержать **ровно один** JSON-документ. `json.Decoder` читает
|
||
первый документ и останавливается, поэтому файл с хвостом принимался целиком,
|
||
а его вторая половина молча не применялась;
|
||
- партия проверяется целиком **до** первой записи в базу;
|
||
- применение идёт **одной транзакцией**;
|
||
- пир `bootstrap-admin-peer` защищён от перезаписи: его секрет продублирован
|
||
в `/etc/hy2xs/bootstrap-admin.secret`.
|
||
|
||
Транзакция — не дублирование проверки, а закрытие другого класса отказов.
|
||
Валидация проверяет содержимое файла и ничего не знает о том, что уже лежит в
|
||
базе. Пусть существуют `A(auth_id=aaa, name=alice1)` и
|
||
`B(auth_id=bbb, name=bob123)`, а файл несёт `(auth_id=aaa, name=bob123)`: поиск
|
||
найдёт A по `auth_id` и попытается переименовать её в `bob123` — прямо в
|
||
`UNIQUE(name)`. Пока записи применялись по одной, всё, что шло в файле до
|
||
конфликтной строки, оставалось применённым, и откатить это оператор уже не мог.
|
||
|
||
Криптоматериал (digest и шифртекст секретов) считается **до** открытия
|
||
транзакции: эти операции читают ключи из той же таблицы `config`, и держать на
|
||
ней открытую запись во время AES по каждой из тысяч записей незачем.
|
||
|
||
## Два слоя работы с конфигом Hysteria
|
||
|
||
Это важное архитектурное разделение.
|
||
|
||
| Слой | Назначение | Поведение при неизвестных полях |
|
||
| --- | --- | --- |
|
||
| Типизированная модель | значения профиля для панели и генерация клиентских ссылок | неизвестные поля не отображаются |
|
||
| Сырой YAML | экспорт, а также перечень секций верхнего уровня | неизвестные поля **сохраняются** |
|
||
|
||
Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
|
||
|
||
Поэтому:
|
||
|
||
- экспорт читает исходный YAML и сохраняет структуру документа целиком;
|
||
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
|
||
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
|
||
|
||
### Третий слой: проекция на production-профиль
|
||
|
||
Панель показывает **то, что записано в файле**, — и это отдельный слой, а не
|
||
типизированная модель целиком.
|
||
|
||
```text
|
||
/etc/hysteria/config.yaml
|
||
↓
|
||
типизированная модель (значения) + сырой YAML (ключи верхнего уровня)
|
||
↓
|
||
BuildHysteria2Profile()
|
||
↓
|
||
Hysteria2ProfileVo: секции профиля + список расхождений
|
||
↓
|
||
страница (только чтение)
|
||
```
|
||
|
||
**Что было.** Ответ отдавал внутреннюю модель серверного конфига целиком, а
|
||
frontend накладывал его на полный объект значений по умолчанию
|
||
(`DeepRequired` + merge). В результате экран отвечал не на тот вопрос:
|
||
|
||
```text
|
||
вопрос, который решает оператор:
|
||
что реально написано в /etc/hysteria/config.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 — только чтение, и теперь это верно на всех уровнях
|
||
|
||
Страница прямо сообщает, что конфигом владеет `hy2xs-orchestrator reconfigure`.
|
||
Маршрутов записи серверного конфига в API нет — они удалены вместе с мёртвым
|
||
updater/config-write слоем.
|
||
|
||
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
|
||
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
|
||
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
|
||
передавались в них без `v-model`, то есть у их событий `update:*` не было ни
|
||
одного слушателя. Оператор мог добавить outbound, увидеть его в списке и уйти в
|
||
уверенности, что изменил конфигурацию сервера; изменения не переживали даже
|
||
переключения вкладки. У одного из них цена была ещё и измеримой: редактор списка
|
||
значений работал на `vuedraggable`, которая тянула в bundle полную сборку Vue с
|
||
рантайм-компилятором шаблонов — около полумегабайта ради перетаскивания тегов в
|
||
недоступной для редактирования форме.
|
||
|
||
Сначала все три были приведены к отображению, а вместе с переходом на проекцию
|
||
профиля удалены целиком вместе со своими компонентами: пока «универсальный
|
||
редактор» существует в дереве, он отрастает заново.
|
||
|
||
### Санитайз экспорта
|
||
|
||
Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
|
||
|
||
- пароли обфускации (`obfs.*.password`);
|
||
- `trafficStats.secret`;
|
||
- `access_token` в auth-URL и учётные данные, встроенные в URL;
|
||
- `auth.password`, `auth.userpass`;
|
||
- учётные данные ACME DNS-провайдера;
|
||
- **неизвестные** поля с секретоподобным именем: `password`, `passwd`,
|
||
`passphrase`, `secret`, `token`, `credential`, `apiKey` / `api_key`,
|
||
`privateKey` / `private_key`, `accessKey`, `secretKey`, `authorization`,
|
||
`cookie`, `bearer`, `signature`.
|
||
|
||
Последний пункт — обратная сторона сохранения неизвестных полей: новое
|
||
upstream-поле с секретом вырезается ещё до того, как HY2XS про него узнает.
|
||
|
||
### Как формулируется гарантия
|
||
|
||
Точная формулировка:
|
||
|
||
> вырезаются известные секреты и неизвестные поля с секретоподобным именем.
|
||
|
||
Не «любой будущий секрет будет автоматически удалён». Обобщённый sanitizer
|
||
работает по именам полей и не может предугадать произвольное имя, которое
|
||
upstream выберет для нового секрета. Список маркеров синхронизирован с
|
||
`orchestrator/src/lib/redaction.ts`; при появлении нового поля его нужно
|
||
добавить в оба места.
|
||
|
||
Пути к файлам (`tls.cert`, `tls.key`, `ech.keyPath`, `tls.clientCA`) секретами
|
||
не считаются и остаются читаемыми — они нужны для диагностики.
|
||
|
||
## Модель современной схемы Hysteria
|
||
|
||
Модель админки понимает актуальную серверную схему, даже там, где UI не позволяет ничего включить: `obfs.gecko`, `ech`, `congestion`, `mimic`, `realm`, `tls.clientCA`, `quic.disableStatelessReset`, `bandwidth.disableLossCompensation`, `masquerade.proxy.xForwarded`.
|
||
|
||
Смысл в том, чтобы admin **понимал текущую upstream-схему**, а не считал неизвестными поля собственного конфига.
|
||
|
||
## Генерация клиентских ссылок
|
||
|
||
- тип обфускации и пароль берутся из фактического конфига одинаково для всех поддерживаемых типов (`gecko`, `salamander`);
|
||
- неизвестный тип обфускации в ссылку не попадает: лучше отсутствие параметра, чем параметр, который клиент не понимает;
|
||
- публичный endpoint берётся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen` или Host-заголовка запроса;
|
||
- SNI берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется;
|
||
- `minPacketSize`/`maxPacketSize` Gecko в ссылку не помещаются — поэтому HY2XS держит их на upstream-defaults `512/1200`.
|
||
|
||
## Правила ответственности
|
||
|
||
### Source of truth
|
||
|
||
- runtime transport layer: Hysteria2
|
||
- операторский UI layer: HY2XS admin
|
||
- install lifecycle: оркестратор HY2XS
|
||
- deploy facts: `post-install.env`
|
||
|
||
## Production lifecycle Hysteria2
|
||
|
||
В production package HY2XS admin не скачивает и не обновляет бинарь Hysteria2 самостоятельно.
|
||
|
||
Правильная модель:
|
||
|
||
- Hysteria2 устанавливается install-оркестратором с official upstream;
|
||
- Hysteria2 запускается отдельным `hysteria-server.service`;
|
||
- HY2XS admin работает как operator UI и HTTP auth/traffic layer;
|
||
- HY2XS admin не запускается от root;
|
||
- смена версии Hysteria2 через UI **отсутствует как API**;
|
||
- список upstream releases не является частью operator UI baseline;
|
||
- port hopping не является частью production path.
|
||
|
||
### Удалённые операции: почему не заглушки
|
||
|
||
Маршруты, которые продукт принципиально не поддерживает, **удалены**, а не
|
||
оставлены отвечающими «feature disabled»:
|
||
|
||
| Удалённый маршрут | Кто владеет операцией |
|
||
| --- | --- |
|
||
| `POST /hysteria2ChangeVersion` | install-оркестратор |
|
||
| `GET /listRelease` | build layer |
|
||
| `POST /config/updateHysteria2Config` | install-оркестратор |
|
||
| `POST /config/importHysteria2Config` | install-оркестратор |
|
||
| `POST /config/restartServer` | systemd |
|
||
| `POST /config/uploadCertFile` | оператор + оркестратор |
|
||
| `GET /config/hysteria2AcmePath` | не имел потребителя |
|
||
| `POST /config/exportConfig` | выгружал JWT- и peer-ключи в открытом виде |
|
||
| `POST /config/importConfig` | позволял подменить те же ключи |
|
||
|
||
Причины две.
|
||
|
||
Во-первых, API-контракт не должен даже обещать updater, которого у продукта
|
||
нет: маршрут, всегда возвращающий отказ, вводит в заблуждение.
|
||
|
||
Во-вторых, это лишняя attack surface и технический мусор от прежней
|
||
архитектуры.
|
||
|
||
Вместе с маршрутами удалены соответствующие клиентские функции фронтенда,
|
||
кнопки и строки i18n. Кнопка, которая гарантированно возвращает ошибку, —
|
||
не «точка расширения на будущее», а дефект UX. Возвращение любого из этих
|
||
маршрутов ломает acceptance-проверку сборки.
|
||
|
||
Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**:
|
||
`GET /config/getHysteria2Config` и `POST /config/exportHysteria2Config`.
|
||
|
||
### Правило доступа объявлено один раз
|
||
|
||
Пускать пира или нет — решает одна функция, `peerAccessDenied`
|
||
(`apps/service/peer_access.go`). Её же применяет принудительное отключение в
|
||
cron. Второго экземпляра правила в продукте нет, и это главное свойство слоя
|
||
доступа.
|
||
|
||
Границы:
|
||
|
||
| условие | результат |
|
||
| --- | --- |
|
||
| `disabled = 1` | доступа нет |
|
||
| `quotaBytes = -1` | квота не ограничена |
|
||
| `download + upload >= quotaBytes` (при `quotaBytes >= 0`) | доступа нет |
|
||
| `expiresAt > 0` и `now >= expiresAt` | доступа нет |
|
||
| `bannedUntil > now` | доступа нет |
|
||
| строка без любого из этих полей | доступа нет |
|
||
|
||
Каждая граница выбрана по смыслу самого названия, и три из них стоит назвать
|
||
отдельно:
|
||
|
||
* **`quotaBytes = 0` — это ноль байтов, а не безлимит.** Единственный способ
|
||
снять ограничение — `-1`.
|
||
* **`usage = quota` — лимит исчерпан.** Счётчики растут порциями по ответу
|
||
Traffic Stats API, поэтому точное равенство — обычный исход очередного
|
||
сбора, а не экзотика.
|
||
* **`bannedUntil = now` — блокировка уже закончилась.** Она задаётся как «до»
|
||
момента, и наступивший момент означает её конец.
|
||
|
||
Строка без решающего поля трактуется как повреждённая: все эти колонки
|
||
объявлены `NOT NULL DEFAULT`, поэтому `NULL` здесь означать может только
|
||
повреждение, а на пути принятия решения о доступе оно обязано вести к отказу.
|
||
|
||
Что было до этого: правило существовало в двух экземплярах — SQL-условием
|
||
внутри `Hysteria2Auth` и другим SQL-условием внутри cron, — и расходилось ровно
|
||
на перечисленных границах. Практическое следствие было хуже расхождения: пир с
|
||
исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
|
||
никогда, потому что cron требовал СТРОГОГО превышения. Он продолжал
|
||
пользоваться доступом, пока не переподключался по своей воле.
|
||
|
||
### Отзыв доступа к VPN состоит из двух половин
|
||
|
||
Панель не управляет жизненным циклом Hysteria, но доступом пиров управляет
|
||
целиком — и здесь требуются обе половины официального контракта Hysteria.
|
||
|
||
```text
|
||
сохранённое состояние закрывает БУДУЩИЕ обращения к HTTP-auth
|
||
POST /kick завершает УЖЕ УСТАНОВЛЕННУЮ сессию
|
||
```
|
||
|
||
Ни одна половина не работает по отдельности. Сохранённое состояние видит только
|
||
`peerAccessDenied`, то есть оно проверяется при следующем подключении;
|
||
установленная QUIC-сессия живёт своей жизнью и сама не разрывается. Обратно:
|
||
`/kick` завершает сессию, но клиент немедленно переподключается — поэтому
|
||
официальная документация Hysteria и требует одновременной блокировки в auth
|
||
backend.
|
||
|
||
**Порядок обязателен и обратному не подлежит:**
|
||
|
||
```text
|
||
1. записать долговременное состояние
|
||
2. POST /kick по authId пира
|
||
```
|
||
|
||
При обратном порядке клиент успевает переподключиться в окне между разрывом и
|
||
записью и остаётся на связи с уже изменённым пиром.
|
||
|
||
#### Какие операции проходят по этому пути
|
||
|
||
Разрыв нужен не только при отключении пира. Полный список — и это ровно те
|
||
операции, которые способны сделать живую сессию устаревшей:
|
||
|
||
| операция | что рвётся |
|
||
| --- | --- |
|
||
| отключение пира (`disabled = 1`) | сессия пира |
|
||
| временная блокировка | сессия пира |
|
||
| смена секрета | сессия пира: прежние учётные данные недействительны |
|
||
| квота урезана так, что доступ уже закрыт | сессия пира |
|
||
| срок перенесён в прошлое | сессия пира |
|
||
| лимит устройств снижен | все сессии пира |
|
||
| **удаление пира** | сессия пира, по запомненному `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`» ради этих секунд ввела бы второй
|
||
идентификатор сессии, то есть ровно то состояние, из-за которого отзыв и не
|
||
сходился.
|
||
|
||
Правило асимметрично намеренно: **ограничение применяется немедленно,
|
||
послабление — нет.** Увеличенная квота, продлённый срок, поднятый лимит
|
||
устройств, правка имени или пометки сессию не рвут — у оператора нет причины
|
||
ронять работающее соединение, расширяя пиру права.
|
||
|
||
Все они идут через один `reconcileLiveSessions`, а он — через единственный в
|
||
продукте вход к `/kick`, `disconnectAuthIDs`. Отдельных методов разрыва для
|
||
каждой операции нет намеренно: иначе «изменение применили, а сессию завершить
|
||
забыли» появлялось бы заново с каждой новой операцией — именно так это и
|
||
случилось с удалением и импортом.
|
||
|
||
#### Удаление пира
|
||
|
||
```text
|
||
1. прочитать пира и запомнить его authId
|
||
2. записать disabled = 1
|
||
3. POST /kick по запомненному authId
|
||
4. удалить строку
|
||
```
|
||
|
||
Шаг 1 существует потому, что вместе со строкой исчезает `authId` — то есть
|
||
единственное, чем сессию можно было бы завершить. Прежняя реализация состояла
|
||
из одного шага 4, и состояние после неё было **невосстановимым**: удалённый пир
|
||
пользовался доступом до собственного переподключения, и сделать с этим было уже
|
||
нечего.
|
||
|
||
Исходы:
|
||
|
||
| что произошло | состояние |
|
||
| --- | --- |
|
||
| запись не удалась | строка не изменена, удаления не было |
|
||
| разрыв не удался | строка осталась с `disabled = 1`, новые подключения запрещены |
|
||
| разрыв прошёл, удаление не удалось | строка отключена, сессия уже завершена |
|
||
|
||
Ни один не возвращает пиру доступ. Оператор повторяет удаление тем же
|
||
действием.
|
||
|
||
#### Импорт партии
|
||
|
||
Импорт — это bulk state replacement: он переписывает `authId`, секрет, квоту,
|
||
срок и `disabled` существующего пира целиком. Поэтому:
|
||
|
||
```text
|
||
валидация партии
|
||
↓
|
||
подготовка криптоматериала
|
||
↓
|
||
транзакция: собрать СТАРЫЕ authId + применить все изменения
|
||
↓
|
||
COMMIT
|
||
↓
|
||
дедупликация + POST /kick одной пачкой
|
||
```
|
||
|
||
Оба слова в «внутри транзакции, после commit» существенны. **Внутри** — потому
|
||
что после commit старого `authId` в базе уже нет. **После** — потому что `/kick`
|
||
до commit оставляет клиенту окно, в котором он переподключается к ещё не
|
||
изменённому пиру.
|
||
|
||
Рвутся сессии **всех** существующих записей партии, а не тех, у кого изменилось
|
||
конкретное поле. Это сознательно более простой контракт, чем diff по семи
|
||
полям: не появляется второй таблицы правил «какие поля импорта считаются
|
||
access-changing», то есть второго места, где политика может разойтись с
|
||
`peerAccessDenied`. Цена — существующие пиры партии один раз переподключаются;
|
||
для административной операции переноса это нормальная цена. Вновь созданные
|
||
пиры не рвутся: до импорта их сессий существовать не могло.
|
||
|
||
#### Частичный результат
|
||
|
||
**Неудача разрыва не откатывает сохранённое состояние.** Безопасная половина
|
||
достигнута; возвращать доступ из-за отказа второго шага нельзя. Операция
|
||
отвечает кодом `peer_disconnect_failed`, панель показывает его предупреждением
|
||
и обновляет список.
|
||
|
||
**И не оставляет систему в этом состоянии навсегда.** Сообщить оператору о
|
||
частичном результате недостаточно: повторить второй шаг он может не всегда.
|
||
|
||
```text
|
||
операция повтор той же операции после неудачного /kick
|
||
───────────────────────────────────────────────────────────────
|
||
disabled = 1 работает: условие смотрит на ЗАПРОШЕННОЕ состояние
|
||
удаление работает: строка осталась с disabled = 1
|
||
блокировка работает: cron видит banned_until через peerAccessDenied
|
||
квота / срок работает: cron видит их через peerAccessDenied
|
||
maxDevices ↓ НЕ работает: 1 < 1 -> false, разрыва больше не будет
|
||
импорт old→new НЕ работает: в базе уже new, повтор разорвёт ЕГО
|
||
смена секрета НЕ работает: в базе уже новый digest, старая сессия
|
||
неотличима от законной
|
||
```
|
||
|
||
Три нижние строки не имели механизма схождения вовсе. Первые две закрывает cron
|
||
— см. «Сверка живых сессий» ниже; третью — ротация `auth_id`, после которой она
|
||
сводится к первым двум: старое поколение становится orphan. Отдельной таблицы retry, очереди отложенных
|
||
операций и хранимого «списка того, что не удалось разорвать» для этого не
|
||
нужно: `/online` и есть список живых сессий.
|
||
|
||
Формулировка сообщения **не называет конкретную операцию**: через этот код
|
||
отчитываются все восемь строк таблицы выше, а для удалённого пира фраза «новые
|
||
подключения пира запрещены» была бы просто бессмысленной.
|
||
|
||
**Отключение и временная блокировка — разные механизмы**, и смешивать их
|
||
нельзя:
|
||
|
||
| | снимается | назначение |
|
||
| --- | --- | --- |
|
||
| `disabled` | только руками оператора | отзыв доступа |
|
||
| `banned_until` | истекает сам | временная блокировка |
|
||
|
||
Поэтому `disconnectAuthIDs` не пишет в базу вовсе и не читает её: он принимает
|
||
готовые `authId`. Включение пира не сбрасывает `banned_until`, а снятие
|
||
блокировки не включает отключённого пира.
|
||
|
||
**Состояние службы по systemd в этом пути не участвует.** Оно годится только для
|
||
отображения и не является основанием ни для отказа операции, ни для её пропуска
|
||
— ни здесь, ни в cron. Ответ даёт само обращение к Traffic Stats API. Как
|
||
именно читается состояние службы и почему у него три значения, а не два — см.
|
||
«Состояние службы и доступность API — разные факты».
|
||
|
||
### Цикл учёта принадлежит планировщику
|
||
|
||
`CronHandleAccount` выполняется синхронно, под одним мьютексом на весь цикл, и
|
||
строго в этом порядке:
|
||
|
||
```text
|
||
TryLock (пропустить тик, если предыдущий ещё идёт)
|
||
↓
|
||
порт Traffic Stats API + секрет
|
||
↓
|
||
GET /traffic?clear=1 → записать дельты в счётчики пиров
|
||
↓
|
||
GET /online → сверить живые сессии → POST /kick
|
||
```
|
||
|
||
Порядок обязателен: enforcement принимает решение по счётчикам, значит счётчики
|
||
должны быть уже обновлены. Раньше обе половины запускались параллельными
|
||
горутинами внутри ещё одной горутины, поэтому превышение квоты замечалось в
|
||
лучшем случае со следующего тика, а планировщик считал джобу завершённой почти
|
||
мгновенно — `StopCron()` не ждал настоящей работы, и после закрытия SQLite
|
||
горутины продолжали в неё писать.
|
||
|
||
**Учёт трафика — операционная граница, а не биллинг.** Чтение `GET
|
||
/traffic?clear=1` деструктивно по контракту Traffic Stats API: счётчики
|
||
Hysteria обнуляются сразу после отправки ответа, поэтому каждая дельта
|
||
существует ровно в одном экземпляре. Если запись в SQLite не удалась, дельта
|
||
потеряна безвозвратно — это записывается в журнал уровнем `error`, но не
|
||
компенсируется. Полностью закрыть окно можно только сменой модели учёта:
|
||
недеструктивный `GET /traffic` плюс долговременные checkpoint'ы верхних
|
||
счётчиков и вычисление дельты на стороне админки. Это отдельная подсистема с
|
||
обработкой перезапуска и сброса счётчиков Hysteria, и в `1.0.0` она намеренно
|
||
не вводится. Квота здесь — операционный предел доступа, а не учёт с финансово
|
||
значимым каждым байтом.
|
||
|
||
#### Сверка живых сессий
|
||
|
||
Cron обходит **каждый `authId`, который Hysteria считает живым**, а не тех
|
||
пиров, которых удалось найти в базе. Разница между этими двумя формулировками и
|
||
есть то, что делает частичный результат обратимым.
|
||
|
||
```text
|
||
для каждого authId из GET /online:
|
||
|
||
выборка пиров не удалась → не рвать НИЧЕГО (цикл прекращается)
|
||
строки в базе нет → /kick (пир удалён, переподписан импортом
|
||
либо это отозванное поколение
|
||
учётных данных)
|
||
peerAccessDenied → /kick (disabled / квота / срок / блокировка)
|
||
maxDevices непригоден → /kick (повреждённая граница — не «безлимит»)
|
||
устройств > maxDevices → /kick (лимит снижен, сессии остались)
|
||
```
|
||
|
||
Прежний обход выглядел как `ListPeer("auth_id in ?") → range peers`, поэтому
|
||
идентификатор, которому в базе ничего не соответствует, **молча выпадал**. А
|
||
именно он и остаётся единственным следом сессии после неудачного второго шага
|
||
удаления или импорта: `auth_id` в строке уже заменён либо строки нет вовсе, и
|
||
восстановить состояние переподключением невозможно — авторизация нового
|
||
значения не знает, а старая сессия живёт своей жизнью.
|
||
|
||
**Отказ базы не является основанием рвать сессии.** «Пира нет» и «прочитать не
|
||
удалось» — разные ответы, и трактовать второй как первый значит отключить всех
|
||
подключённых пиров сразу при недоступной SQLite. Ошибка выборки прекращает
|
||
цикл до единого обращения к `/kick`.
|
||
|
||
**Число устройств берётся из upstream-контракта, а не из предположения.**
|
||
`GET /online` по официальной документации Traffic Stats API возвращает
|
||
количество экземпляров клиента Hysteria («устройства»), а не число proxy-потоков.
|
||
|
||
Предикат живых сессий (`peerSessionNeedsReconcile`) **не является вторым
|
||
экземпляром политики доступа**: `disabled`, квота, срок и блокировка остаются
|
||
целиком за `peerAccessDenied`, и предикат его вызывает, а не повторяет. Своего
|
||
у него ровно одно — инвариант, которого в хранимом состоянии пира нет: сколько
|
||
устройств сейчас на связи.
|
||
|
||
Побочное следствие того же обхода — уборка учёта выданных разрешений: цикл
|
||
учёта единственный в продукте знает фактическую картину подключений целиком.
|
||
|
||
### Ограничение устройств проверяется fail-closed
|
||
|
||
`maxDevices` проверяется по `/online` Traffic Stats API, который возвращает
|
||
число экземпляров клиента Hysteria — то есть именно «устройства», а не число
|
||
proxy-потоков.
|
||
|
||
Недоступность этого API **отклоняет подключение** и пишет запись уровня
|
||
`error`. Выбор направления осознанный: запрос авторизации приходит от самой
|
||
Hysteria, значит она жива, а её Traffic Stats API слушает loopback внутри того
|
||
же процесса — его недоступность является аномалией, а не штатным состоянием.
|
||
Обратный выбор молча снимал бы объявленный в панели лимит со всех пиров сразу,
|
||
и единственным следом этого была бы строка `warn` в журнале.
|
||
|
||
У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой отказ
|
||
затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация:
|
||
доступность Traffic Stats API входит в install/doctor smoke.
|
||
|
||
### Состояние службы и доступность 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 напрямую.
|
||
|
||
#### Лимит выдерживает параллельные подключения
|
||
|
||
Сравнения ответа `/online` с `maxDevices` недостаточно. Ответив «allow», панель
|
||
не создаёт подключение — его только начинает устанавливать Hysteria, и клиент
|
||
попадает в статистику позже. Поэтому:
|
||
|
||
```text
|
||
A: GET /online -> 2 B: GET /online -> 2
|
||
max = 3
|
||
A: 2 < 3 -> allow B: 2 < 3 -> allow
|
||
стало 4
|
||
```
|
||
|
||
Объявленный «Лимит устройств: 3» превышался ровно тем способом, от которого
|
||
лимит и должен защищать. Мьютекс вокруг `/online` это не чинит: следующий
|
||
запрос, даже строго после первого, продолжает видеть прежнее число.
|
||
|
||
Панель ведёт собственный учёт уже выданных, но ещё не проявившихся разрешений
|
||
(`apps/service/peer_admission.go`):
|
||
|
||
```text
|
||
1. обычная проверка политики доступа
|
||
2. взять замок ЭТОГО пира
|
||
3. GET /online
|
||
4. снять протухшие разрешения
|
||
5. рост online означает, что столько же разрешений превратились в подключения
|
||
6. решение по сумме: online + выданные разрешения
|
||
7. свободно -> занять место и allow; иначе deny
|
||
8. отпустить замок
|
||
```
|
||
|
||
Учёт **process-local**: HY2XS — один процесс на одном сервере с Hysteria, и ни
|
||
Redis, ни таблицы в базе, ни распределённых блокировок для этого не нужно.
|
||
Разрешение живёт 30 секунд — величина внутренняя и пользовательской настройкой
|
||
не является: это компенсация задержки между ответом авторизации и появлением
|
||
клиента в статистике, а не политика доступа. Если клиент авторизовался и не
|
||
подключился, резервация исчезает сама.
|
||
|
||
#### Снимки `/online` не переупорядочиваются
|
||
|
||
Учёта разрешений самого по себе оказалось недостаточно, и это отдельный дефект,
|
||
а не оттенок предыдущего. Пока сетевой запрос выполнялся **вне** блокировки,
|
||
снимки приходили в резервацию в произвольном порядке, и более старый откатывал
|
||
учёт назад:
|
||
|
||
```text
|
||
A получил разрешение при online = 0; pending = [A], lastOnline = 0
|
||
B прочитал online = 0 и задержался на обратном пути
|
||
A подключился — Hysteria показывает online = 1
|
||
C прочитал online = 1 и вошёл ПЕРВЫМ:
|
||
разрешение A признано проявившимся, lastOnline = 1, C отклонён
|
||
B входит со своим устаревшим 0 -> lastOnline снова 0 -> B ДОПУЩЕН
|
||
```
|
||
|
||
При `maxDevices = 1` подключений становилось два. Детектор гонок здесь
|
||
бесполезен **принципиально**: вся работа с памятью защищена мьютексом, и гонка
|
||
логическая, а не по памяти. Доказать такое свойство может только семантический
|
||
тест.
|
||
|
||
Поэтому последовательность «прочитать `/online` → занять место» выполняется под
|
||
замком, и замок этот — **по `authId`, а не один на процесс**. Внутри него идёт
|
||
сетевой запрос: общий замок выстроил бы подключения всех пиров в очередь за
|
||
одним HTTP-обменом. Конкурируют только авторизации одного и того же пира, а их
|
||
упорядоченность и есть требуемое свойство. Время удержания ограничено сверху
|
||
таймаутом обращения к Traffic Stats API.
|
||
|
||
Оба механизма нужны одновременно и закрывают разные половины:
|
||
|
||
```text
|
||
замок по authId — снимки не переупорядочиваются
|
||
учёт разрешений — снимок не успевает измениться к следующему запросу
|
||
```
|
||
|
||
Чего механизм не обещает: без обратного вызова от Hysteria «соединение
|
||
установлено / не установлено» математически точной системы резервирования не
|
||
построить. Он закрывает конкретные и реальные случаи — параллельные HTTP-auth
|
||
одного процесса — и делает это fail-closed. Случайное превышение лимита по
|
||
любой другой причине устраняет сверка живых сессий в цикле учёта.
|
||
|
||
### Что нельзя делать
|
||
|
||
- собирать admin-компонент на target server;
|
||
- скачивать admin-компонент на target из внешнего репозитория;
|
||
- склеивать unit Hysteria2 и unit HY2XS admin в один сервис;
|
||
- раздувать оркестратор из-за особенностей панели;
|
||
- использовать HY2XS admin как updater бинаря Hysteria2;
|
||
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
|
||
- считать `disabled=1` завершённым отзывом доступа без `/kick`;
|
||
- откатывать `disabled` из-за неудачи `/kick`;
|
||
- писать `banned_until` из пути отключения пира;
|
||
- пропускать проверку лимита устройств, когда Traffic Stats API не ответил;
|
||
- заводить второй предикат доступа рядом с `peerAccessDenied` — в том числе в
|
||
виде SQL-условия внутри выборки;
|
||
- обращаться к `/kick` мимо `disconnectAuthIDs`;
|
||
- удалять пира, не запомнив его `authId` и не завершив сессию до удаления;
|
||
- разрывать сессии импорта до `COMMIT` либо по новым `authId`;
|
||
- читать `/online` вне замка пира на пути авторизации: устаревший снимок
|
||
возвращает уже занятое место, и детектор гонок этого не показывает;
|
||
- заводить один замок авторизации на процесс: внутри него идёт сетевой запрос;
|
||
- пропускать в цикле учёта `authId`, которому в базе ничего не соответствует, —
|
||
это единственный след сессии после неудавшегося разрыва при удалении и
|
||
импорте;
|
||
- трактовать отказ базы как «пира нет» и рвать по нему сессии;
|
||
- заводить таблицу отложенных операций или очередь retry ради схождения:
|
||
список живых сессий уже есть, и это `/online`;
|
||
- запускать работу джобы учёта в отсоединённых горутинах: планировщик обязан
|
||
её видеть, иначе `StopCron()` вернётся раньше, чем она закончит;
|
||
- считать квоту биллинговым учётом: чтение `/traffic?clear=1` деструктивно;
|
||
- делать срок жизни pending-разрешения пользовательской настройкой;
|
||
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
|
||
- выгружать конфиг с секретами в открытом виде.
|
||
|
||
## Что фиксировать в `post-install.env`
|
||
|
||
Минимум:
|
||
|
||
- `HY2XS_ADMIN_ENABLED`
|
||
- `HY2XS_ADMIN_SOURCE`
|
||
- `HY2XS_ADMIN_BUILD_ID`
|
||
- `HY2XS_ADMIN_BIND_HOST`
|
||
- `HY2XS_ADMIN_PORT`
|
||
- `HY2XS_ADMIN_INSTALL_DIR`
|
||
- `HY2XS_ADMIN_DATA_DIR`
|
||
- `HY2XS_ADMIN_LOG_DIR`
|
||
|
||
## Учётные данные и аутентификация
|
||
|
||
### Bootstrap-учётные данные приходят от оркестратора
|
||
|
||
Первая учётная запись администратора создаётся из `HY2XS_ADMIN_INITIAL_PASSWORD`,
|
||
пир установщика — из `HY2XS_ADMIN_CON_PASS`. Оба значения задаёт оркестратор
|
||
через `/etc/hy2xs/hy2xs.env`, а копию кладёт в
|
||
`/etc/hy2xs/bootstrap-admin.secret`.
|
||
|
||
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
|
||
старте** с сообщением, называющим причину и способ починки.
|
||
|
||
Раньше она в этом случае придумывала пароль сама и печатала его двумя
|
||
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
|
||
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
|
||
утечки, у такого пароля была вторая проблема: его не знал никто, кроме журнала.
|
||
Попадание в эту ветку означает не «нужно что-то придумать», а повреждённый
|
||
контракт запуска, и реакция на него должна быть громкой.
|
||
|
||
Для пира установщика цена ошибки ещё конкретнее: его секрет продублирован в
|
||
`bootstrap-admin.secret`, откуда его читает проверка machine-auth в smoke
|
||
оркестратора. Придуманный админкой секрет разошёлся бы с файлом, и проверка
|
||
подключения провалилась бы на корректном во всём остальном сервере.
|
||
|
||
Порядок проверок при этом такой: сначала выясняется, нужно ли вообще создавать
|
||
запись, и только потом требуется переменная. Перезапуск уже установленного
|
||
сервиса без неё работает штатно.
|
||
|
||
Тот же принцип распространён на machine token `HYSTERIA2_TRAFFIC_STATS_SECRET`.
|
||
Раньше при пустом env и пустой базе админка генерировала его сама, и это было
|
||
хуже, чем отказ: записать значение в `/etc/hysteria/config.yaml` она не может —
|
||
файл принадлежит оркестратору и доступен ей только на чтение, что проверяет
|
||
smoke. Результат — сервис объявлял себя здоровым, а machine auth переставал
|
||
совпадать, потому что Hysteria продолжала слать прежний токен. Допустимых
|
||
состояний три:
|
||
|
||
| env | база | поведение |
|
||
| --- | --- | --- |
|
||
| задан | любое | база синхронизируется с env: владелец значения — оркестратор |
|
||
| пуст | токен есть | рабочее состояние, ничего не меняется |
|
||
| пуст | пусто | **отказ старта** |
|
||
|
||
Вторая строка нужна для ручного `systemctl start` без `EnvironmentFile`: она не
|
||
изобретает контракт, а использует уже согласованный.
|
||
|
||
### Пир установщика защищён во всех путях записи
|
||
|
||
`bootstrap-admin-peer` нельзя переименовать, переподписать или занять его имя
|
||
чужим пиром — ни импортом, ни через обычные формы панели. Раньше проверка стояла
|
||
только в импорте, то есть ровно то, ради чего она существует, делалось через
|
||
интерфейс.
|
||
|
||
Удаление и отключение при этом **разрешены**: после установки это обычный
|
||
действующий доступ, секрет которого лежит ещё и в файле на диске, и оператор
|
||
обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не
|
||
создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке.
|
||
|
||
### Отзыв пира установщика необратим
|
||
|
||
Разрешать удаление имеет смысл только вместе с этим свойством, иначе панель
|
||
предлагает операцию, которой не выполняет.
|
||
|
||
Признаком «создавать пир или нет» служит отметка `BOOTSTRAP_PEER_SEEDED` в
|
||
таблице `config`. Она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не
|
||
«существует сейчас», и выставляется той же транзакцией, которой создаётся сам
|
||
пир.
|
||
|
||
Раньше признаком было наличие строки в таблице пиров, и отзыв доступа не
|
||
переживал перезапуск сервиса:
|
||
|
||
```text
|
||
оператор удаляет bootstrap-admin-peer
|
||
↓
|
||
доступ действительно исчезает
|
||
|
||
systemctl restart hy2xs-admin (или reboot)
|
||
↓
|
||
InitSql → ensureSecureBootstrapPeer
|
||
↓
|
||
строки нет → прочитать HY2XS_ADMIN_CON_PASS из /etc/hy2xs/hy2xs.env
|
||
↓
|
||
создать пира заново → ТОТ ЖЕ секрет снова действует
|
||
```
|
||
|
||
Переменная никуда не девается из `hy2xs.env` — её читает systemd-юнит, — поэтому
|
||
восстановление происходило **молча**: ни строки в журнале, а в списке пиров
|
||
запись просто снова есть. Отзыв учётных данных, который не переживает restart,
|
||
отзывом не является.
|
||
|
||
Транзакционность здесь не формальность: раздельная запись вернула бы прежнее
|
||
поведение в новой форме, потому что падение процесса между созданием пира и
|
||
записью отметки снова дало бы следующему старту «ещё не создавался».
|
||
|
||
Что при этом происходит с файлом на диске: `/etc/hy2xs/bootstrap-admin.secret`
|
||
принадлежит оркестратору, админка его не трогает, и после отзыва он содержит уже
|
||
недействующее значение. Это ожидаемо — файл является копией того, что установка
|
||
записала в базу, а не источником истины для рантайма.
|
||
|
||
Отключение (`Disabled = 1`) остаётся вторым, обратимым способом: `Hysteria2Auth`
|
||
выбирает пира с условием `disabled = 0`, поэтому доступ закрывается сразу, а
|
||
запись сохраняется.
|
||
|
||
Жизненный цикл закреплён тестами в `apps/dao/bootstrap_peer_test.go`: создание,
|
||
перезапуск без изменений, удаление с последующими перезапусками, отключение,
|
||
отказ старта без `HY2XS_ADMIN_CON_PASS` на чистой базе и успешный перезапуск без
|
||
неё на установленной.
|
||
|
||
### Токены и пароли
|
||
|
||
Токены выписываются и проверяются `golang-jwt/jwt/v5`. Переход с v3 —
|
||
не косметика: у `github.com/golang-jwt/jwt` v3.2.2 есть GO-2025-3553, у которой
|
||
**нет исправленной версии в ветке v3** (`Fixed in: N/A`), а уязвимый код
|
||
достигается из разбора токена, то есть с неаутентифицированного запроса.
|
||
Обновлять было нечего — лечится только сменой мажорной ветки.
|
||
|
||
Заодно закрыт тихий недостаток прежней реализации: `keyfunc` возвращал ключ,
|
||
**не проверяя алгоритм подписи**, то есть набор допустимых алгоритмов
|
||
фактически задавал сам токен. Сейчас разбор ограничен `jwt.WithValidMethods`,
|
||
проверяются `issuer` и обязательное наличие срока жизни, а пустой `JWT_SECRET`
|
||
считается повреждённым состоянием, а не ключом нулевой длины.
|
||
|
||
Пароли администратора хранятся ровно в одном формате — bcrypt. Ветка сравнения
|
||
с несолёным SHA-224 (формат предыдущего поколения) удалена: в v1 такой хеш не
|
||
может появиться — миграции таблицы `account` удалены, установка возможна только
|
||
на чистый хост, а конфигурация 0.x отклоняется по `HY2XS_CONFIG_SCHEMA_VERSION`.
|
||
Compatibility-ветка пережила слой совместимости, ради которого существовала, и
|
||
осталась запасным путём проверки пароля слабым алгоритмом в обработчике логина.
|
||
|
||
Все секреты продукта генерируются одним примитивом `util.RandomString` с
|
||
отбраковкой (rejection sampling): прежняя реализация брала остаток байта от
|
||
деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали
|
||
примерно на четверть чаще остальных.
|
||
|
||
## Инварианты
|
||
|
||
Схема считается корректной, если:
|
||
|
||
1. HY2XS admin приезжает на target уже в составе пакета
|
||
2. target не скачивает и не собирает admin-компонент
|
||
3. HY2XS admin работает отдельным сервисом
|
||
4. HY2XS admin не меняет install-only scope оркестратора
|
||
5. Hysteria2 остаётся внешним vanilla upstream-компонентом
|
||
6. HY2XS admin не выступает updater-менеджером Hysteria2
|
||
7. `trafficStats.secret` не связан с `JWT_SECRET`
|
||
8. экспорт конфига сохраняет неизвестные upstream-поля
|
||
9. экспорт конфига не содержит секретов
|
||
10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
|
||
11. планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер
|
||
12. значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет
|
||
13. партия настроек применяется целиком или не применяется вовсе
|
||
14. в таблице `config` нет ключей без потребителя
|
||
15. bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
|
||
16. любой журнал, покидающий сервер, проходит санитайз
|
||
17. пароль администратора хранится ровно в одном формате — bcrypt
|
||
18. правило доступа объявлено ровно один раз (`peerAccessDenied`), и авторизация
|
||
с принудительным отключением спрашивают именно его
|
||
19. каждая операция, способная сделать живую сессию устаревшей, проходит через
|
||
один `reconcileLiveSessions`, а он — через единственный вход к `/kick`
|
||
20. долговременное состояние записывается ДО разрыва, и неудача разрыва его не
|
||
откатывает
|
||
21. удаление пира завершает его сессию до того, как исчезнет `authId`
|
||
22. импорт разрывает старые сессии после `COMMIT` и по старым `authId`
|
||
23. джоба учёта выполняется синхронно, и `StopCron()` её дожидается
|
||
24. лимит устройств не превышается параллельными запросами авторизации — в том
|
||
числе когда снимки `/online` приходят в обратном порядке
|
||
25. цикл учёта сверяет КАЖДУЮ живую сессию из `/online`, а не только тех пиров,
|
||
которых удалось найти в базе; отказ базы при этом не рвёт ничего
|
||
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-якоря: санитайзер выгрузки следует
|
||
по ссылкам
|