Сборка не собиралась: два контракта приёмки роняли её на корректном коде.
verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.
Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.
Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.
Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.
updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.
Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.
Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
достижима с неаутентифицированного запроса; набор алгоритмов подписи
зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
проверявшая меньше, чем middleware.
Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
40 KiB
Admin panel: HY2XS admin
Цель документа
Зафиксировать модель работы с admin-панелью: HY2XS admin является штатным компонентом HY2XS, а не внешней зависимостью, которую target server где-то добывает во время установки.
Место компонента в системе
Проект состоит из компонентов двух разных типов:
- Hysteria2 — external runtime dependency. Ванильный upstream-бинарник, который оркестратор забирает из официального источника во время установки.
- HY2XS admin — native HY2XS component. Исходный код лежит в репозитории, компонент собирается production builder'ом вместе с остальными артефактами HY2XS.
Это противопоставление и есть основная архитектурная граница.
Состав компонента
- исходный код admin-компонента хранится в
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.
Пространства имён 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:
{ "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
Пока логировался 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 выполняется в три прохода:
- проверка партии целиком — права на ключ, дубликаты ключей, значения;
- одна транзакция базы;
- применение к рантайму.
Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал. Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на втором — оператор получал отказ на запрос, который систему уже изменил.
Тест на этот случай существовал, но ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен по построению.
Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы
проверяется явно: TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden.
Экспорт пиров: два режима, а не флаг
| Кнопка | Запрос | Что внутри |
|---|---|---|
| Экспорт настроек | POST /api/peer-export |
список пиров без секретов |
| Резервная копия | POST /api/peer-export?includeSecrets=true |
то же плюс действующие секреты подключения |
Разница здесь продуктовая, а не техническая, и её нельзя оставлять неявной. Записи с пустым секретом при импорте получают новые секреты. То есть перенос обычным экспортом восстанавливает пиров, их квоты, лимиты и счётчики — но все существующие клиентские ссылки после него перестают работать.
Раньше кнопка в панели была одна и всегда звала маршрут без includeSecrets,
хотя документация называла эту пару механизмом переноса пиров. Оператор
переносил пиров и обнаруживал, что все клиенты отвалились.
Резервная копия содержит фактические учётные данные доступа к VPN в открытом виде, поэтому запускается только через явное подтверждение с описанием риска. Такой файл следует хранить как пароль и удалять после завершения переноса.
Импорт пиров
Импорт проверяется так же строго, как обычное создание пира: те же правила для
имени, quota, maxDevices, disabled, длины секрета. Дополнительно:
- неизвестные поля в JSON отклоняются, а не игнорируются молча;
- файл обязан содержать ровно один JSON-документ.
json.Decoderчитает первый документ и останавливается, поэтому файл с хвостом принимался целиком, а его вторая половина молча не применялась; - партия проверяется целиком до первой записи в базу;
- применение идёт одной транзакцией;
- пир
bootstrap-admin-peerзащищён от перезаписи: его секрет продублирован в/etc/hy2xs/bootstrap-admin.secret.
Транзакция — не дублирование проверки, а закрытие другого класса отказов.
Валидация проверяет содержимое файла и ничего не знает о том, что уже лежит в
базе. Пусть существуют A(auth_id=aaa, name=alice1) и
B(auth_id=bbb, name=bob123), а файл несёт (auth_id=aaa, name=bob123): поиск
найдёт A по auth_id и попытается переименовать её в bob123 — прямо в
UNIQUE(name). Пока записи применялись по одной, всё, что шло в файле до
конфликтной строки, оставалось применённым, и откатить это оператор уже не мог.
Криптоматериал (digest и шифртекст секретов) считается до открытия
транзакции: эти операции читают ключи из той же таблицы config, и держать на
ней открытую запись во время AES по каждой из тысяч записей незачем.
Два слоя работы с конфигом Hysteria
Это важное архитектурное разделение.
| Слой | Назначение | Поведение при неизвестных полях |
|---|---|---|
| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
| Сырой YAML | экспорт и сохранение | неизвестные поля сохраняются |
Причина: если бы экспорт работал через типизированную модель (Unmarshal → структура → Marshal), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
Поэтому:
- экспорт читает исходный YAML и сохраняет структуру документа целиком;
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
Санитайз экспорта
Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
- пароли обфускации (
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/maxPacketSizeGecko в ссылку не помещаются — поэтому HY2XS держит их на upstream-defaults512/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.
Что нельзя делать
- собирать admin-компонент на target server;
- скачивать admin-компонент на target из внешнего репозитория;
- склеивать unit Hysteria2 и unit HY2XS admin в один сервис;
- раздувать оркестратор из-за особенностей панели;
- использовать HY2XS admin как updater бинаря Hysteria2;
- использовать
JWT_SECRETкакtrafficStats.secretдля Hysteria API; - экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
- выгружать конфиг с секретами в открытом виде.
Что фиксировать в post-install.env
Минимум:
HY2XS_ADMIN_ENABLEDHY2XS_ADMIN_SOURCEHY2XS_ADMIN_BUILD_IDHY2XS_ADMIN_BIND_HOSTHY2XS_ADMIN_PORTHY2XS_ADMIN_INSTALL_DIRHY2XS_ADMIN_DATA_DIRHY2XS_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
оркестратора. Придуманный админкой секрет разошёлся бы с файлом, и проверка
подключения провалилась бы на корректном во всём остальном сервере.
Порядок проверок при этом такой: сначала выясняется, нужно ли вообще создавать запись, и только потом требуется переменная. Перезапуск уже установленного сервиса без неё работает штатно.
Пир установщика защищён во всех путях записи
bootstrap-admin-peer нельзя переименовать, переподписать или занять его имя
чужим пиром — ни импортом, ни через обычные формы панели. Раньше проверка стояла
только в импорте, то есть ровно то, ради чего она существует, делалось через
интерфейс.
Удаление и отключение при этом разрешены: после установки это обычный действующий доступ, секрет которого лежит ещё и в файле на диске, и оператор обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке.
Токены и пароли
Токены выписываются и проверяются 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): прежняя реализация брала остаток байта от
деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали
примерно на четверть чаще остальных.
Инварианты
Схема считается корректной, если:
- HY2XS admin приезжает на target уже в составе пакета
- target не скачивает и не собирает admin-компонент
- HY2XS admin работает отдельным сервисом
- HY2XS admin не меняет install-only scope оркестратора
- Hysteria2 остаётся внешним vanilla upstream-компонентом
- HY2XS admin не выступает updater-менеджером Hysteria2
trafficStats.secretне связан сJWT_SECRET- экспорт конфига сохраняет неизвестные upstream-поля
- экспорт конфига не содержит секретов
- сгенерированная
hysteria2://ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую - планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер
- значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет
- партия настроек применяется целиком или не применяется вовсе
- в таблице
configнет ключей без потребителя - bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
- любой журнал, покидающий сервер, проходит санитайз
- пароль администратора хранится ровно в одном формате — bcrypt