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