Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
52 KiB
Admin panel: HY2XS admin
Контракты панели, закреплённые тестами и релизными гейтами — отрисовка иконок, структурированные ошибки, необязательные поля и генерация секретов, атрибуция — вынесены в отдельный документ: 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/; - 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 в открытом виде, поэтому запускается только через явное подтверждение с описанием риска. Такой файл следует хранить как пароль и удалять после завершения переноса.
Резервная копия либо полная, либо её нет
Для includeSecrets=true правило строгое: если секрет хотя бы одного пира
получить не удалось — расшифровка не прошла или шифртекста нет вовсе — весь
запрос завершается ошибкой, называющей проблемного пира, и файл не создаётся.
Раньше оба этих случая обрабатывались молча: пир уезжал в файл с пустым полем
secret, а запрос отвечал успехом. Оператор получал файл, выглядящий полным:
[{"name":"A","secret":"..."},
{"name":"B","secret":""},
{"name":"C","secret":"..."}]
Обнаруживалось это уже после импорта на новом сервере: B получал новый сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима ровно в том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»; частичный результат под этим именем — худший из возможных ответов.
Безопасная выгрузка (includeSecrets=false) шифртекст не трогает вовсе и
повреждённых данных не замечает: пустой secret там — не потеря, а весь смысл
режима.
Секреты пиров хранятся только зашифрованными, в единственном формате v1: +
AES-GCM. Значение без этого префикса — не «формат предыдущего поколения», а
повреждённые данные, и расшифровка на них отказывает. Прежняя реализация
возвращала такое содержимое как якобы успешно расшифрованный секрет, то есть
мусор из колонки уходил и в клиентскую ссылку, и в резервную копию.
Импорт пиров
Импорт проверяется так же строго, как обычное создание пира: те же правила для
имени, quota, maxDevices, disabled, длины секрета. Дополнительно:
- неизвестные поля в JSON отклоняются, а не игнорируются молча;
- файл обязан содержать ровно один JSON-документ.
json.Decoderчитает первый документ и останавливается, поэтому файл с хвостом принимался целиком, а его вторая половина молча не применялась; - партия проверяется целиком до первой записи в базу;
- применение идёт одной транзакцией;
- пир
bootstrap-admin-peerзащищён от перезаписи: его секрет продублирован в/etc/hy2xs/bootstrap-admin.secret.
Транзакция — не дублирование проверки, а закрытие другого класса отказов.
Валидация проверяет содержимое файла и ничего не знает о том, что уже лежит в
базе. Пусть существуют A(auth_id=aaa, name=alice1) и
B(auth_id=bbb, name=bob123), а файл несёт (auth_id=aaa, name=bob123): поиск
найдёт A по auth_id и попытается переименовать её в bob123 — прямо в
UNIQUE(name). Пока записи применялись по одной, всё, что шло в файле до
конфликтной строки, оставалось применённым, и откатить это оператор уже не мог.
Криптоматериал (digest и шифртекст секретов) считается до открытия
транзакции: эти операции читают ключи из той же таблицы config, и держать на
ней открытую запись во время AES по каждой из тысяч записей незачем.
Два слоя работы с конфигом Hysteria
Это важное архитектурное разделение.
| Слой | Назначение | Поведение при неизвестных полях |
|---|---|---|
| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
| Сырой YAML | экспорт и сохранение | неизвестные поля сохраняются |
Причина: если бы экспорт работал через типизированную модель (Unmarshal → структура → Marshal), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
Поэтому:
- экспорт читает исходный YAML и сохраняет структуру документа целиком;
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
Третий слой: модель отображения
У типизированной модели есть подслой, о котором стоит сказать отдельно, потому что он определяет, как устроены шаблоны страницы Hysteria.
Hysteria2ServerConfig описывает то, что приходит по сети, и почти все его
секции необязательны — ровно так же, как в upstream YAML. Форма же обращается к
ним напрямую: dataForm.tls.cert, dataForm.acme.dns.config,
dataForm.resolver.https.sni.
Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
Современный vue-tsc даёт на этом 141 ошибку TS18048 — и он прав: обращение
через возможно отсутствующий объект падает в рантайме. Спасало то, что форма
строится merge'ем поверх полного объекта значений по умолчанию, то есть
инвариант «секция есть всегда» существовал, но держался на порядке присваиваний
внутри компонента и нигде не был выражен типом.
Закрыто одним преобразованием на границе, а не 141 оператором ?. и не
as any:
ответ API (Hysteria2ServerConfig, секции необязательны)
↓
normalizeHysteriaViewModel()
↓
Hysteria2ServerConfigView — все секции обязательны
↓
шаблон
Hysteria2ServerConfigView выводится из Hysteria2ServerConfig типом, а не
пишется вторым списком полей. Поэтому новая секция в схеме ломает компиляцию на
объекте значений по умолчанию — то есть поле upstream нельзя молча не
отобразить.
Побочное следствие: v-if в шаблоне перестали проверять присутствие секции и
проверяют только то, что действительно определяет выбор ветки. Например для
обфускации это dataForm.obfs.type === 'gecko' вместо
dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko — вторая половина
дублировала первую и существовала только из-за необязательности типа.
Этот слой не участвует в экспорте: выгрузка идёт от исходного YAML и сохраняет неизвестные поля, поэтому их потеря в модели отображения безвредна.
Страница Hysteria — только чтение, и теперь это верно на всех уровнях
Страница отрисована с :disabled="true" и прямо сообщает, что конфигом владеет
hy2xs-orchestrator reconfigure. Маршрутов записи серверного конфига в API нет
— они удалены вместе с мёртвым updater/config-write слоем.
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
передаются в них как :outbounds=, :tags=, :map-object= — без v-model,
то есть у их событий update:* нет ни одного слушателя. Оператор мог добавить
outbound, увидеть его в списке и уйти в уверенности, что изменил конфигурацию
сервера; изменения не переживали даже переключения вкладки.
Все три приведены к отображению. У одного из них цена была ещё и измеримой:
редактор списка значений работал на vuedraggable, которая поставляется
UMD-сборкой, поэтому её require("vue") разрешался в полную сборку Vue вместе с
рантайм-компилятором шаблонов — около полумегабайта в bundle ради
перетаскивания тегов в недоступной для редактирования форме.
Санитайз экспорта
Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
- пароли обфускации (
obfs.*.password); trafficStats.secret;access_tokenв auth-URL и учётные данные, встроенные в URL;auth.password,auth.userpass;- учётные данные ACME DNS-провайдера;
- неизвестные поля с секретоподобным именем:
password,passwd,passphrase,secret,token,credential,apiKey/api_key,privateKey/private_key,accessKey,secretKey,authorization,cookie,bearer,signature.
Последний пункт — обратная сторона сохранения неизвестных полей: новое upstream-поле с секретом вырезается ещё до того, как HY2XS про него узнает.
Как формулируется гарантия
Точная формулировка:
вырезаются известные секреты и неизвестные поля с секретоподобным именем.
Не «любой будущий секрет будет автоматически удалён». Обобщённый sanitizer
работает по именам полей и не может предугадать произвольное имя, которое
upstream выберет для нового секрета. Список маркеров синхронизирован с
orchestrator/src/lib/redaction.ts; при появлении нового поля его нужно
добавить в оба места.
Пути к файлам (tls.cert, tls.key, ech.keyPath, tls.clientCA) секретами
не считаются и остаются читаемыми — они нужны для диагностики.
Модель современной схемы Hysteria
Модель админки понимает актуальную серверную схему, даже там, где UI не позволяет ничего включить: obfs.gecko, ech, congestion, mimic, realm, tls.clientCA, quic.disableStatelessReset, bandwidth.disableLossCompensation, masquerade.proxy.xForwarded.
Смысл в том, чтобы admin понимал текущую upstream-схему, а не считал неизвестными поля собственного конфига.
Генерация клиентских ссылок
- тип обфускации и пароль берутся из фактического конфига одинаково для всех поддерживаемых типов (
gecko,salamander); - неизвестный тип обфускации в ссылку не попадает: лучше отсутствие параметра, чем параметр, который клиент не понимает;
- публичный endpoint берётся из
HY2XS_PUBLIC_HOST+HY2XS_PUBLIC_PORT, а не изlistenили Host-заголовка запроса; - SNI берётся из ACME-домена, затем из
HY2XS_DOMAIN, затем изHY2XS_PUBLIC_HOST; IP-адрес как SNI не используется; minPacketSize/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
оркестратора. Придуманный админкой секрет разошёлся бы с файлом, и проверка
подключения провалилась бы на корректном во всём остальном сервере.
Порядок проверок при этом такой: сначала выясняется, нужно ли вообще создавать запись, и только потом требуется переменная. Перезапуск уже установленного сервиса без неё работает штатно.
Тот же принцип распространён на 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. Она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не
«существует сейчас», и выставляется той же транзакцией, которой создаётся сам
пир.
Раньше признаком было наличие строки в таблице пиров, и отзыв доступа не переживал перезапуск сервиса:
оператор удаляет 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): прежняя реализация брала остаток байта от
деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали
примерно на четверть чаще остальных.
Инварианты
Схема считается корректной, если:
- 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