Files
HY2XS_flamy/docs/admin/04-admin-panel.md
T
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом
bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от
64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль
длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как
утверждал комментарий, — так вела себя редакция пакета до v0.28.

Следствие: пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на
штатной смене пароля, а при установке падением старта админки, то есть
сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта
было то, что тест закреплял это значение как ожидаемое.

Вместе с ним закрыты три соседних расхождения того же контракта.

Пароль триммился вопреки собственному контракту. util.HashPassword вёл
проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал
strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение
"abcde " принимали все двери продукта и не мог захешировать никто, а первая
учётная запись создавалась не с тем паролем, который оператор записал в
hy2xs.env.

Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы
async-validator, а он сравнивает min/max с String.prototype.length: пароль из
трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера,
который панель не могла объяснить.

hy2xs.env не был форматом. Значения писались интерполяцией, а читались
split("=") с trim(); при этом файл читает не только оркестратор — он объявлен
EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd
срезает краевые пробелы и трактует обратный слеш как escape.

Что сделано:

- контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword
  и dao, а service импортирует util — обратный импорт был бы циклическим, и
  именно поэтому HashPassword завёл собственную копию правила;
- AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в
  оркестраторе и панели; сверяется тестами, читающими Go-исходник;
- одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в
  байтах тегом валидатора не выразить) и код причины admin_password_format,
  называющий обе границы;
- TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет
  контракт сам и падает с текстом, называющим переменную и файл;
- панель считает code points и UTF-8 байты общим adminPasswordFormRule на
  обеих формах вместо встроенных min/max;
- orchestrator/src/lib/envFile.ts — порт конечного автомата
  parse_env_file_internal из systemd и обратный ему кодировщик; экранируются
  только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные
  значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же
  кодировщиком пишется bootstrap-admin.secret;
- управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а
  ввести такой пароль в форму входа всё равно нельзя;
- отрицательная проба smoke сверяет конверт отказа (code 50000,
  invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль
  генерирует, а не берёт из литерала;
- положительная проба читает bootstrap-секрет парсером формата вместо
  grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы.

Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я»,
«abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами
инвертирован; round-trip env-формата на значениях с кавычками, слешами и
краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов
приёмки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:38:04 +05:00

1311 lines
99 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
Это важное архитектурное разделение.
| Слой | Назначение | Поведение при неизвестных полях |
| --- | --- | --- |
| Типизированная модель | значения профиля для панели и генерация клиентских ссылок | неизвестные поля не отображаются |
| Сырой 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`.
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
старте** с сообщением, называющим причину и способ починки.
То же и при значении вне контракта пароля: `HY2XS_ADMIN_INITIAL_PASSWORD`
проверяется против того же правила, что и форма входа
(`apps/credential/admin.go`), и непригодное значение роняет старт с внятным
текстом, а не доходит до `bcrypt.GenerateFromPassword`, чтобы вернуться оттуда
строкой `password length exceeds 72 bytes`. Учётная запись при этом не
создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя.
Пароль читается **как есть**: пробелы по краям объявлены его частью и не
снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap
делал `strings.TrimSpace`, и учётная запись создавалась не с тем паролем,
который оператор записал в `hy2xs.env`.
Раньше она в этом случае придумывала пароль сама и печатала его двумя
`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-якоря: санитайзер выгрузки следует
по ссылкам