Files
HY2XS_flamy/docs/admin/04-admin-panel.md
T
founder 8dcb50a07c fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков
Предыдущий проход сделал правильным порядок «сначала долговременная запись,
потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа
прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не
работал вовсе.

Импорт, заменивший auth_id: после неудавшегося /kick старое значение не
хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а
cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала
строку. Живая сессия оставалась навсегда.

Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет.
Лимит устройств в политику доступа не входит и входить не должен — это
свойство сессий, — поэтому механизма схождения у него не было.

enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из
/online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный
maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом
не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых
сессий уже есть, и это /online.

Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений
закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне
блокировки, поэтому снимки приходили в резервацию в произвольном порядке и
устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не
data race — память защищена мьютексом, и -race здесь молчит принципиально.
Последовательность «прочитать /online -> занять место» выполняется под замком
по authId; глобальный замок не годится, внутри идёт сетевой запрос.

Учёт разрешений больше не растёт бесконечно: запись снималась только на ветке
отказа, поэтому в карте копились удалённые пиры и переписанные импортом
идентификаторы. Уборка идёт по фактической картине подключений.

Гейты приёмки доращены под все три инварианта и проверены в обе стороны.
Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах,
где описывала снятую архитектуру.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
2026-09-02 07:15:43 +05:00

84 KiB
Raw Blame History

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_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:

{ "msg": "POST /internal/hysteria/auth → 200 (2 ms)",
  "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }

Поле msg собирается из тех же величин, что уже лежат в структурных полях, и не добавляет к ним ничего: запись остаётся машиночитаемой, а сообщение существует, чтобы человек мог прочитать строку журнала, не собирая её из шести колонок. Раньше entry.Info() вызывался без аргумента, и logrus записывал "msg":"" для каждого запроса — страница системных логов показывала оператору пустой столбец, точно отражая содержимое файла.

Читаемость сообщения не является лазейкой для query-строки: в msg попадает только путь, и это закреплено тестом, который проверяет обе половины сразу — сообщение непустое И не несёт ни токена, ни знака ?.

Пока логировался 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 выполняется в три прохода:

  1. проверка партии целиком — права на ключ, дубликаты ключей, значения;
  2. одна транзакция базы;
  3. применение к рантайму.

Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал. Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на втором — оператор получал отказ на запрос, который систему уже изменил.

Тест на этот случай существовал, но ставил запрещённый ключ первым и не смотрел в базу — поймать частичное применение он был неспособен по построению. Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы проверяется явно: 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/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.

Правило доступа объявлено один раз

Пускать пира или нет — решает одна функция, peerAccessDenied (apps/service/peer_access.go). Её же применяет принудительное отключение в cron. Второго экземпляра правила в продукте нет, и это главное свойство слоя доступа.

Границы:

условие результат
disabled = 1 доступа нет
quotaBytes = -1 квота не ограничена
download + upload >= quotaBytes (при quotaBytes >= 0) доступа нет
expiresAt > 0 и now >= expiresAt доступа нет
bannedUntil > now доступа нет
строка без любого из этих полей доступа нет

Каждая граница выбрана по смыслу самого названия, и три из них стоит назвать отдельно:

  • quotaBytes = 0 — это ноль байтов, а не безлимит. Единственный способ снять ограничение — -1.
  • usage = quota — лимит исчерпан. Счётчики растут порциями по ответу Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора, а не экзотика.
  • bannedUntil = now — блокировка уже закончилась. Она задаётся как «до» момента, и наступивший момент означает её конец.

Строка без решающего поля трактуется как повреждённая: все эти колонки объявлены NOT NULL DEFAULT, поэтому NULL здесь означать может только повреждение, а на пути принятия решения о доступе оно обязано вести к отказу.

Что было до этого: правило существовало в двух экземплярах — SQL-условием внутри Hysteria2Auth и другим SQL-условием внутри cron, — и расходилось ровно на перечисленных границах. Практическое следствие было хуже расхождения: пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась никогда, потому что cron требовал СТРОГОГО превышения. Он продолжал пользоваться доступом, пока не переподключался по своей воле.

Отзыв доступа к VPN состоит из двух половин

Панель не управляет жизненным циклом Hysteria, но доступом пиров управляет целиком — и здесь требуются обе половины официального контракта Hysteria.

сохранённое состояние   закрывает БУДУЩИЕ обращения к HTTP-auth
POST /kick              завершает УЖЕ УСТАНОВЛЕННУЮ сессию

Ни одна половина не работает по отдельности. Сохранённое состояние видит только peerAccessDenied, то есть оно проверяется при следующем подключении; установленная QUIC-сессия живёт своей жизнью и сама не разрывается. Обратно: /kick завершает сессию, но клиент немедленно переподключается — поэтому официальная документация Hysteria и требует одновременной блокировки в auth backend.

Порядок обязателен и обратному не подлежит:

1. записать долговременное состояние
2. POST /kick по authId пира

При обратном порядке клиент успевает переподключиться в окне между разрывом и записью и остаётся на связи с уже изменённым пиром.

Какие операции проходят по этому пути

Разрыв нужен не только при отключении пира. Полный список — и это ровно те операции, которые способны сделать живую сессию устаревшей:

операция что рвётся
отключение пира (disabled = 1) сессия пира
временная блокировка сессия пира
смена секрета сессия пира: прежние учётные данные недействительны
квота урезана так, что доступ уже закрыт сессия пира
срок перенесён в прошлое сессия пира
лимит устройств снижен все сессии пира
удаление пира сессия пира, по запомненному authId
импорт партии сессии всех существующих пиров партии, по СТАРЫМ authId

Правило асимметрично намеренно: ограничение применяется немедленно, послабление — нет. Увеличенная квота, продлённый срок, поднятый лимит устройств, правка имени или пометки сессию не рвут — у оператора нет причины ронять работающее соединение, расширяя пиру права.

Все они идут через один reconcileLiveSessions, а он — через единственный в продукте вход к /kick, disconnectAuthIDs. Отдельных методов разрыва для каждой операции нет намеренно: иначе «изменение применили, а сессию завершить забыли» появлялось бы заново с каждой новой операцией — именно так это и случилось с удалением и импортом.

Удаление пира

1. прочитать пира и запомнить его authId
2. записать disabled = 1
3. POST /kick по запомненному authId
4. удалить строку

Шаг 1 существует потому, что вместе со строкой исчезает authId — то есть единственное, чем сессию можно было бы завершить. Прежняя реализация состояла из одного шага 4, и состояние после неё было невосстановимым: удалённый пир пользовался доступом до собственного переподключения, и сделать с этим было уже нечего.

Исходы:

что произошло состояние
запись не удалась строка не изменена, удаления не было
разрыв не удался строка осталась с disabled = 1, новые подключения запрещены
разрыв прошёл, удаление не удалось строка отключена, сессия уже завершена

Ни один не возвращает пиру доступ. Оператор повторяет удаление тем же действием.

Импорт партии

Импорт — это bulk state replacement: он переписывает authId, секрет, квоту, срок и disabled существующего пира целиком. Поэтому:

валидация партии
    ↓
подготовка криптоматериала
    ↓
транзакция: собрать СТАРЫЕ authId + применить все изменения
    ↓
COMMIT
    ↓
дедупликация + POST /kick одной пачкой

Оба слова в «внутри транзакции, после commit» существенны. Внутри — потому что после commit старого authId в базе уже нет. После — потому что /kick до commit оставляет клиенту окно, в котором он переподключается к ещё не изменённому пиру.

Рвутся сессии всех существующих записей партии, а не тех, у кого изменилось конкретное поле. Это сознательно более простой контракт, чем diff по семи полям: не появляется второй таблицы правил «какие поля импорта считаются access-changing», то есть второго места, где политика может разойтись с peerAccessDenied. Цена — существующие пиры партии один раз переподключаются; для административной операции переноса это нормальная цена. Вновь созданные пиры не рвутся: до импорта их сессий существовать не могло.

Частичный результат

Неудача разрыва не откатывает сохранённое состояние. Безопасная половина достигнута; возвращать доступ из-за отказа второго шага нельзя. Операция отвечает кодом peer_disconnect_failed, панель показывает его предупреждением и обновляет список.

И не оставляет систему в этом состоянии навсегда. Сообщить оператору о частичном результате недостаточно: повторить второй шаг он может не всегда.

операция          повтор той же операции после неудачного /kick
───────────────────────────────────────────────────────────────
disabled = 1      работает: условие смотрит на ЗАПРОШЕННОЕ состояние
удаление          работает: строка осталась с disabled = 1
блокировка        работает: cron видит banned_until через peerAccessDenied
квота / срок      работает: cron видит их через peerAccessDenied
maxDevices ↓      НЕ работает: 1 < 1 -> false, разрыва больше не будет
импорт old→new    НЕ работает: в базе уже new, повтор разорвёт ЕГО

Две нижние строки не имели механизма схождения вовсе, и обе закрывает cron — см. «Сверка живых сессий» ниже. Отдельной таблицы retry, очереди отложенных операций и хранимого «списка того, что не удалось разорвать» для этого не нужно: /online и есть список живых сессий.

Формулировка сообщения не называет конкретную операцию: через этот код отчитываются все восемь строк таблицы выше, а для удалённого пира фраза «новые подключения пира запрещены» была бы просто бессмысленной.

Отключение и временная блокировка — разные механизмы, и смешивать их нельзя:

снимается назначение
disabled только руками оператора отзыв доступа
banned_until истекает сам временная блокировка

Поэтому disconnectAuthIDs не пишет в базу вовсе и не читает её: он принимает готовые authId. Включение пира не сбрасывает banned_until, а снятие блокировки не включает отключённого пира.

Состояние службы по systemd в этом пути не участвует. util.Exec схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не удалось» в одну ошибку, поэтому Hysteria2IsRunning не является основанием ни для отказа операции, ни для её пропуска — ни здесь, ни в cron. Ответ даёт само обращение к Traffic Stats API.

Цикл учёта принадлежит планировщику

CronHandleAccount выполняется синхронно, под одним мьютексом на весь цикл, и строго в этом порядке:

TryLock (пропустить тик, если предыдущий ещё идёт)
    ↓
порт Traffic Stats API + секрет
    ↓
GET /traffic?clear=1  → записать дельты в счётчики пиров
    ↓
GET /online → сверить живые сессии → POST /kick

Порядок обязателен: enforcement принимает решение по счётчикам, значит счётчики должны быть уже обновлены. Раньше обе половины запускались параллельными горутинами внутри ещё одной горутины, поэтому превышение квоты замечалось в лучшем случае со следующего тика, а планировщик считал джобу завершённой почти мгновенно — StopCron() не ждал настоящей работы, и после закрытия SQLite горутины продолжали в неё писать.

Учёт трафика — операционная граница, а не биллинг. Чтение GET /traffic?clear=1 деструктивно по контракту Traffic Stats API: счётчики Hysteria обнуляются сразу после отправки ответа, поэтому каждая дельта существует ровно в одном экземпляре. Если запись в SQLite не удалась, дельта потеряна безвозвратно — это записывается в журнал уровнем error, но не компенсируется. Полностью закрыть окно можно только сменой модели учёта: недеструктивный GET /traffic плюс долговременные checkpoint'ы верхних счётчиков и вычисление дельты на стороне админки. Это отдельная подсистема с обработкой перезапуска и сброса счётчиков Hysteria, и в 1.0.0 она намеренно не вводится. Квота здесь — операционный предел доступа, а не учёт с финансово значимым каждым байтом.

Сверка живых сессий

Cron обходит каждый authId, который Hysteria считает живым, а не тех пиров, которых удалось найти в базе. Разница между этими двумя формулировками и есть то, что делает частичный результат обратимым.

для каждого authId из GET /online:

  выборка пиров не удалась   →  не рвать НИЧЕГО (цикл прекращается)
  строки в базе нет          →  /kick   (пир удалён либо переподписан)
  peerAccessDenied           →  /kick   (disabled / квота / срок / блокировка)
  maxDevices непригоден      →  /kick   (повреждённая граница — не «безлимит»)
  устройств > maxDevices     →  /kick   (лимит снижен, сессии остались)

Прежний обход выглядел как ListPeer("auth_id in ?") → range peers, поэтому идентификатор, которому в базе ничего не соответствует, молча выпадал. А именно он и остаётся единственным следом сессии после неудачного второго шага удаления или импорта: auth_id в строке уже заменён либо строки нет вовсе, и восстановить состояние переподключением невозможно — авторизация нового значения не знает, а старая сессия живёт своей жизнью.

Отказ базы не является основанием рвать сессии. «Пира нет» и «прочитать не удалось» — разные ответы, и трактовать второй как первый значит отключить всех подключённых пиров сразу при недоступной SQLite. Ошибка выборки прекращает цикл до единого обращения к /kick.

Число устройств берётся из upstream-контракта, а не из предположения. GET /online по официальной документации Traffic Stats API возвращает количество экземпляров клиента Hysteria («устройства»), а не число proxy-потоков.

Предикат живых сессий (peerSessionNeedsReconcile) не является вторым экземпляром политики доступа: disabled, квота, срок и блокировка остаются целиком за peerAccessDenied, и предикат его вызывает, а не повторяет. Своего у него ровно одно — инвариант, которого в хранимом состоянии пира нет: сколько устройств сейчас на связи.

Побочное следствие того же обхода — уборка учёта выданных разрешений: цикл учёта единственный в продукте знает фактическую картину подключений целиком.

Ограничение устройств проверяется fail-closed

maxDevices проверяется по /online Traffic Stats API, который возвращает число экземпляров клиента Hysteria — то есть именно «устройства», а не число proxy-потоков.

Недоступность этого API отклоняет подключение и пишет запись уровня error. Выбор направления осознанный: запрос авторизации приходит от самой Hysteria, значит она жива, а её Traffic Stats API слушает loopback внутри того же процесса — его недоступность является аномалией, а не штатным состоянием. Обратный выбор молча снимал бы объявленный в панели лимит со всех пиров сразу, и единственным следом этого была бы строка warn в журнале.

У maxDevices есть min=1, безлимита не бывает, поэтому такой отказ затрагивает всех пиров одновременно. Это ожидаемое поведение, а не деградация: доступность Traffic Stats API входит в install/doctor smoke.

Путь ОТОБРАЖЕНИЯ остаётся терпимым: дашборд и признак online в списке пиров показывают пустую картину, когда служба остановлена, — это честный ответ на вопрос «кто сейчас на связи».

Лимит выдерживает параллельные подключения

Сравнения ответа /online с maxDevices недостаточно. Ответив «allow», панель не создаёт подключение — его только начинает устанавливать Hysteria, и клиент попадает в статистику позже. Поэтому:

A: GET /online -> 2      B: GET /online -> 2
                 max = 3
A: 2 < 3 -> allow        B: 2 < 3 -> allow
                 стало 4

Объявленный «Лимит устройств: 3» превышался ровно тем способом, от которого лимит и должен защищать. Мьютекс вокруг /online это не чинит: следующий запрос, даже строго после первого, продолжает видеть прежнее число.

Панель ведёт собственный учёт уже выданных, но ещё не проявившихся разрешений (apps/service/peer_admission.go):

1. обычная проверка политики доступа
2. взять замок ЭТОГО пира
3. GET /online
4. снять протухшие разрешения
5. рост online означает, что столько же разрешений превратились в подключения
6. решение по сумме: online + выданные разрешения
7. свободно -> занять место и allow; иначе deny
8. отпустить замок

Учёт process-local: HY2XS — один процесс на одном сервере с Hysteria, и ни Redis, ни таблицы в базе, ни распределённых блокировок для этого не нужно. Разрешение живёт 30 секунд — величина внутренняя и пользовательской настройкой не является: это компенсация задержки между ответом авторизации и появлением клиента в статистике, а не политика доступа. Если клиент авторизовался и не подключился, резервация исчезает сама.

Снимки /online не переупорядочиваются

Учёта разрешений самого по себе оказалось недостаточно, и это отдельный дефект, а не оттенок предыдущего. Пока сетевой запрос выполнялся вне блокировки, снимки приходили в резервацию в произвольном порядке, и более старый откатывал учёт назад:

A получил разрешение при online = 0;  pending = [A], lastOnline = 0
B прочитал online = 0 и задержался на обратном пути
A подключился — Hysteria показывает online = 1
C прочитал online = 1 и вошёл ПЕРВЫМ:
    разрешение A признано проявившимся, lastOnline = 1, C отклонён
B входит со своим устаревшим 0 -> lastOnline снова 0 -> B ДОПУЩЕН

При maxDevices = 1 подключений становилось два. Детектор гонок здесь бесполезен принципиально: вся работа с памятью защищена мьютексом, и гонка логическая, а не по памяти. Доказать такое свойство может только семантический тест.

Поэтому последовательность «прочитать /online → занять место» выполняется под замком, и замок этот — по authId, а не один на процесс. Внутри него идёт сетевой запрос: общий замок выстроил бы подключения всех пиров в очередь за одним HTTP-обменом. Конкурируют только авторизации одного и того же пира, а их упорядоченность и есть требуемое свойство. Время удержания ограничено сверху таймаутом обращения к Traffic Stats API.

Оба механизма нужны одновременно и закрывают разные половины:

замок по authId  — снимки не переупорядочиваются
учёт разрешений  — снимок не успевает измениться к следующему запросу

Чего механизм не обещает: без обратного вызова от Hysteria «соединение установлено / не установлено» математически точной системы резервирования не построить. Он закрывает конкретные и реальные случаи — параллельные HTTP-auth одного процесса — и делает это fail-closed. Случайное превышение лимита по любой другой причине устраняет сверка живых сессий в цикле учёта.

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

  • собирать admin-компонент на target server;
  • скачивать admin-компонент на target из внешнего репозитория;
  • склеивать unit Hysteria2 и unit HY2XS admin в один сервис;
  • раздувать оркестратор из-за особенностей панели;
  • использовать HY2XS admin как updater бинаря Hysteria2;
  • использовать JWT_SECRET как trafficStats.secret для Hysteria API;
  • считать disabled=1 завершённым отзывом доступа без /kick;
  • откатывать disabled из-за неудачи /kick;
  • писать banned_until из пути отключения пира;
  • пропускать проверку лимита устройств, когда Traffic Stats API не ответил;
  • заводить второй предикат доступа рядом с peerAccessDenied — в том числе в виде SQL-условия внутри выборки;
  • обращаться к /kick мимо disconnectAuthIDs;
  • удалять пира, не запомнив его authId и не завершив сессию до удаления;
  • разрывать сессии импорта до COMMIT либо по новым authId;
  • читать /online вне замка пира на пути авторизации: устаревший снимок возвращает уже занятое место, и детектор гонок этого не показывает;
  • заводить один замок авторизации на процесс: внутри него идёт сетевой запрос;
  • пропускать в цикле учёта authId, которому в базе ничего не соответствует, — это единственный след сессии после неудавшегося разрыва при удалении и импорте;
  • трактовать отказ базы как «пира нет» и рвать по нему сессии;
  • заводить таблицу отложенных операций или очередь retry ради схождения: список живых сессий уже есть, и это /online;
  • запускать работу джобы учёта в отсоединённых горутинах: планировщик обязан её видеть, иначе StopCron() вернётся раньше, чем она закончит;
  • считать квоту биллинговым учётом: чтение /traffic?clear=1 деструктивно;
  • делать срок жизни pending-разрешения пользовательской настройкой;
  • экспортировать конфиг 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

Учётные данные и аутентификация

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): прежняя реализация брала остаток байта от деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали примерно на четверть чаще остальных.

Инварианты

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

  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:// ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
  11. планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер
  12. значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет
  13. партия настроек применяется целиком или не применяется вовсе
  14. в таблице config нет ключей без потребителя
  15. bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
  16. любой журнал, покидающий сервер, проходит санитайз
  17. пароль администратора хранится ровно в одном формате — bcrypt
  18. правило доступа объявлено ровно один раз (peerAccessDenied), и авторизация с принудительным отключением спрашивают именно его
  19. каждая операция, способная сделать живую сессию устаревшей, проходит через один reconcileLiveSessions, а он — через единственный вход к /kick
  20. долговременное состояние записывается ДО разрыва, и неудача разрыва его не откатывает
  21. удаление пира завершает его сессию до того, как исчезнет authId
  22. импорт разрывает старые сессии после COMMIT и по старым authId
  23. джоба учёта выполняется синхронно, и StopCron() её дожидается
  24. лимит устройств не превышается параллельными запросами авторизации — в том числе когда снимки /online приходят в обратном порядке
  25. цикл учёта сверяет КАЖДУЮ живую сессию из /online, а не только тех пиров, которых удалось найти в базе; отказ базы при этом не рвёт ничего
  26. неудавшийся разрыв не оставляет систему в несогласованном состоянии навсегда: сессия удалённого либо переподписанного пира и превышение maxDevices устраняются очередным циклом учёта