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

397 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`](../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](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`:
```json
{ "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://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую