Files
HY2XS_flamy/docs/04-admin-panel.md
T
founder 672d455467 fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора
Hardening-проход перед первой сборкой на Debian. Три из найденного не
воспроизводились ни на одном dry-run и проявились бы только на живом сервере.

Установка

* preflight внутри install вызывался дважды и оба раза проверял clean-host.
  Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json,
  записанный после первого preflight, и опознавался как маркер посторонней
  установки: КАЖДАЯ чистая установка падала сразу после apt-get с
  fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста —
  условие входа в операцию, возможности платформы проверяются уже внутри
  PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания.

* PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs,
  ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом
  запускал оркестратор с его собственным preflight. Отказ того preflight
  объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже
  созданном каталоге оркестратора. Отследить владение мутацией невозможно,
  пока мутируют двое: install.sh больше не изменяет ничего, раскладку
  выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали
  в owned_paths. Как следствие удалено деление clean-host на фазы.

* diagnosticsCollect стояла перед rollback обычным await в install и в
  reconfigure. На заполненном диске она падает сама и отменяла откат целиком.
  Диагностика — best effort, откат — обязателен.

* reconfigure/repair выбирали записываемую фазу отказа регулярным выражением
  по тексту ошибки. Переведено на ownership-флаги.

Секреты

* Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria
  обращается к /internal/hysteria/auth?access_token=<секрет> при каждом
  подключении пира, поэтому действующий machine token оседал открытым текстом
  в hy2xs-admin.log, который отдаётся через ExportLog и попадает в
  diagnostics-бандл. Логируется путь; значения query не пишутся, имена —
  пишутся. Канала было два: gin.Default() печатает path?query в stdout,
  оттуда в journald и в тот же бандл, — панель переведена на gin.New() +
  Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь
  проходят санитайз. Сравнение токена — constant time.

* Config API позволял прочитать и подменить ключи приложения: getConfig и
  listConfig принимали произвольный ключ, а проверка записи была denylist'ом
  из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал
  master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут
  getConfig удалён целиком — потребителей у него не было ни одного.

Пиры

* Импорт применялся по одной записи вне транзакции, вопреки собственному
  контракту. Валидация не знает, что уже лежит в базе: cross-conflict по
  UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной
  транзакцией, криптоматериал считается до её открытия.

* Файл импорта мог содержать хвостовой JSON-документ, который молча не
  применялся. После разбора проверяется io.EOF.

* Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и
  подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и
  прежние клиентские ссылки после переноса переставали работать.

Сборка

* Два stale-грепа в приёмке роняли build.sh в самом конце, внутри
  verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй
  совпадал с router_test.go, который перечисляет удалённые маршруты, потому
  что проверяет их отсутствие: добавление регрессионного теста ломало сборку.

* verify_archive требовал наличия мутирующей строки в install.sh. Инвариант
  перевёрнут: их не должно быть ни одной.

Очистка

* Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы
  listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком
  сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из
  обычных доков, в docs/14 он остаётся — там это имена объектов для удаления.

* Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry:
  203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ
  резолвера отделён от отсутствия A-записи.

Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов,
приёмка прогнана против дерева.
2026-08-28 05:27:10 +05:00

27 KiB
Raw Blame History

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_PORTExecStart … -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-бандла оркестратора.

Импорт и экспорт

Операция Статус
Экспорт пиров (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:

Ключ Чтение Запись
HYSTERIA2_TRAFFIC_TIME да да
RESET_TRAFFIC_CRON да да
HYSTERIA2_CONFIG_REMARK да нет
HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_STATS_SECRET нет нет, владелец — оркестратор
JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY нет нет
любой другой нет нет

Список — allowlist, и это структурное решение, а не стилистическое. Denylist требует, чтобы автор каждого нового ключа вспомнил про этот файл: забытый ключ при denylist сразу публичен, при allowlist — сразу закрыт. Отказ по умолчанию не зависит от внимательности.

Маршрут GET /api/config/getConfig удалён целиком: потребителей у него не было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь. Право записи HYSTERIA2_CONFIG_REMARK тоже убрано: панель его только отображает.

Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, — «этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет», и он ведёт оператора к hy2xs-orchestrator reconfigure.

Оба оставшихся экспорта формируются в памяти и отдаются прямо в ответ. Раньше они шли через os.Create в /var/lib/hy2xs-admin/export/, и файл там оставался навсегда — при ?includeSecrets=true это означало расшифрованные секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога export/ больше не существует.

Экспорт пиров: два режима, а не флаг

Кнопка Запрос Что внутри
Экспорт настроек 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/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.

Что нельзя делать

  • собирать 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_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

Инварианты

Схема считается корректной, если:

  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:// ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую