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 скриптов, приёмка прогнана против дерева.
27 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-бандла оркестратора.
Импорт и экспорт
| Операция | Статус |
|---|---|
Экспорт пиров (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/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
Инварианты
Схема считается корректной, если:
- 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://ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую