Files
HY2XS_flamy/docs/admin/04-admin-panel.md
T
founder 6d1686b2be fix(admin): свести access-control к одному правилу и одному пути отзыва
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.

Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.

Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.

Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.

Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.

Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.

Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
2026-09-01 20:46:21 +05:00

1016 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
Это важное архитектурное разделение.
| Слой | Назначение | Поведение при неизвестных полях |
| --- | --- | --- |
| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
| Сырой YAML | экспорт и сохранение | неизвестные поля **сохраняются** |
Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
Поэтому:
- экспорт читает исходный YAML и сохраняет структуру документа целиком;
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
### Третий слой: модель отображения
У типизированной модели есть подслой, о котором стоит сказать отдельно, потому
что он определяет, как устроены шаблоны страницы 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, секции необязательны)
normalizeHysteriaViewModel()
Hysteria2ServerConfigView — все секции обязательны
шаблон
```
`Hysteria2ServerConfigView` выводится из `Hysteria2ServerConfig` типом, а не
пишется вторым списком полей. Поэтому новая секция в схеме ломает компиляцию на
объекте значений по умолчанию — то есть поле upstream нельзя молча не
отобразить.
Побочное следствие: `v-if` в шаблоне перестали проверять присутствие секции и
проверяют только то, что действительно определяет выбор ветки. Например для
обфускации это `dataForm.obfs.type === 'gecko'` вместо
`dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko` — вторая половина
дублировала первую и существовала только из-за необязательности типа.
Этот слой не участвует в экспорте: выгрузка идёт от исходного YAML и сохраняет
неизвестные поля, поэтому их потеря в модели отображения безвредна.
### Страница Hysteria — только чтение, и теперь это верно на всех уровнях
Страница отрисована с `:disabled="true"` и прямо сообщает, что конфигом владеет
`hy2xs-orchestrator reconfigure`. Маршрутов записи серверного конфига в API нет
— они удалены вместе с мёртвым updater/config-write слоем.
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
передаются в них как `:outbounds=`, `:tags=`, `:map-object=` — без `v-model`,
то есть у их событий `update:*` нет ни одного слушателя. Оператор мог добавить
outbound, увидеть его в списке и уйти в уверенности, что изменил конфигурацию
сервера; изменения не переживали даже переключения вкладки.
Все три приведены к отображению. У одного из них цена была ещё и измеримой:
редактор списка значений работал на `vuedraggable`, которая поставляется
UMD-сборкой, поэтому её `require("vue")` разрешался в полную сборку Vue вместе с
рантайм-компилятором шаблонов — около полумегабайта в bundle ради
перетаскивания тегов в недоступной для редактирования форме.
### Санитайз экспорта
Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
- пароли обфускации (`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` |
Правило асимметрично намеренно: **ограничение применяется немедленно,
послабление — нет.** Увеличенная квота, продлённый срок, поднятый лимит
устройств, правка имени или пометки сессию не рвут — у оператора нет причины
ронять работающее соединение, расширяя пиру права.
Все они идут через один `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`, панель показывает его предупреждением
и обновляет список.
Формулировка сообщения **не называет конкретную операцию**: через этот код
отчитываются все восемь строк таблицы выше, а для удалённого пира фраза «новые
подключения пира запрещены» была бы просто бессмысленной.
**Отключение и временная блокировка — разные механизмы**, и смешивать их
нельзя:
| | снимается | назначение |
| --- | --- | --- |
| `disabled` | только руками оператора | отзыв доступа |
| `banned_until` | истекает сам | временная блокировка |
Поэтому `disconnectAuthIDs` не пишет в базу вовсе и не читает её: он принимает
готовые `authId`. Включение пира не сбрасывает `banned_until`, а снятие
блокировки не включает отключённого пира.
**Состояние службы по systemd в этом пути не участвует.** `util.Exec`
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
удалось» в одну ошибку, поэтому `Hysteria2IsRunning` не является основанием ни
для отказа операции, ни для её пропуска — ни здесь, ни в cron. Ответ даёт само
обращение к Traffic Stats API.
### Цикл учёта принадлежит планировщику
`CronHandleAccount` выполняется синхронно, под одним мьютексом на весь цикл, и
строго в этом порядке:
```text
TryLock (пропустить тик, если предыдущий ещё идёт)
порт Traffic Stats API + секрет
GET /traffic?clear=1 → записать дельты в счётчики пиров
GET /online → применить peerAccessDenied → POST /kick
```
Порядок обязателен: enforcement принимает решение по счётчикам, значит счётчики
должны быть уже обновлены. Раньше обе половины запускались параллельными
горутинами внутри ещё одной горутины, поэтому превышение квоты замечалось в
лучшем случае со следующего тика, а планировщик считал джобу завершённой почти
мгновенно — `StopCron()` не ждал настоящей работы, и после закрытия SQLite
горутины продолжали в неё писать.
**Учёт трафика — операционная граница, а не биллинг.** Чтение `GET
/traffic?clear=1` деструктивно по контракту Traffic Stats API: счётчики
Hysteria обнуляются сразу после отправки ответа, поэтому каждая дельта
существует ровно в одном экземпляре. Если запись в SQLite не удалась, дельта
потеряна безвозвратно — это записывается в журнал уровнем `error`, но не
компенсируется. Полностью закрыть окно можно только сменой модели учёта:
недеструктивный `GET /traffic` плюс долговременные checkpoint'ы верхних
счётчиков и вычисление дельты на стороне админки. Это отдельная подсистема с
обработкой перезапуска и сброса счётчиков Hysteria, и в `1.0.0` она намеренно
не вводится. Квота здесь — операционный предел доступа, а не учёт с финансово
значимым каждым байтом.
### Ограничение устройств проверяется 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.
Путь ОТОБРАЖЕНИЯ остаётся терпимым: дашборд и признак `online` в списке пиров
показывают пустую картину, когда служба остановлена, — это честный ответ на
вопрос «кто сейчас на связи».
#### Лимит выдерживает параллельные подключения
Сравнения ответа `/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. GET /online (вне блокировки: сеть не должна сериализовать все подключения)
3. снять протухшие разрешения
4. рост online означает, что столько же разрешений превратились в подключения
5. решение по сумме: online + выданные разрешения
6. свободно -> занять место и allow; иначе deny
```
Учёт **process-local**: HY2XS — один процесс на одном сервере с Hysteria, и ни
Redis, ни таблицы в базе, ни распределённых блокировок для этого не нужно.
Разрешение живёт 30 секунд — величина внутренняя и пользовательской настройкой
не является: это компенсация задержки между ответом авторизации и появлением
клиента в статистике, а не политика доступа. Если клиент авторизовался и не
подключился, резервация исчезает сама.
Чего механизм не обещает: без обратного вызова от 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`;
- запускать работу джобы учёта в отсоединённых горутинах: планировщик обязан
её видеть, иначе `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. лимит устройств не превышается параллельными запросами авторизации