Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от 64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Следствие: пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а при установке падением старта админки, то есть сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта было то, что тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта. Пароль триммился вопреки собственному контракту. util.HashPassword вёл проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение "abcde " принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в hy2xs.env. Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы async-validator, а он сравнивает min/max с String.prototype.length: пароль из трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера, который панель не могла объяснить. hy2xs.env не был форматом. Значения писались интерполяцией, а читались split("=") с trim(); при этом файл читает не только оркестратор — он объявлен EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd срезает краевые пробелы и трактует обратный слеш как escape. Что сделано: - контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword и dao, а service импортирует util — обратный импорт был бы циклическим, и именно поэтому HashPassword завёл собственную копию правила; - AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в оркестраторе и панели; сверяется тестами, читающими Go-исходник; - одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в байтах тегом валидатора не выразить) и код причины admin_password_format, называющий обе границы; - TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет контракт сам и падает с текстом, называющим переменную и файл; - панель считает code points и UTF-8 байты общим adminPasswordFormRule на обеих формах вместо встроенных min/max; - orchestrator/src/lib/envFile.ts — порт конечного автомата parse_env_file_internal из systemd и обратный ему кодировщик; экранируются только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же кодировщиком пишется bootstrap-admin.secret; - управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а ввести такой пароль в форму входа всё равно нельзя; - отрицательная проба smoke сверяет конверт отказа (code 50000, invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль генерирует, а не берёт из литерала; - положительная проба читает bootstrap-секрет парсером формата вместо grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы. Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я», «abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами инвертирован; round-trip env-формата на значениях с кавычками, слешами и краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов приёмки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 KiB
Admin panel: HY2XS admin
Контракты панели, закреплённые тестами и релизными гейтами — отрисовка иконок, структурированные ошибки, необязательные поля и генерация секретов, атрибуция — вынесены в отдельный документ: 15-ui-contracts.md.
Цель документа
Зафиксировать модель работы с admin-панелью: HY2XS admin является штатным компонентом HY2XS, а не внешней зависимостью, которую target server где-то добывает во время установки.
Место компонента в системе
Проект состоит из компонентов двух разных типов:
- Hysteria2 — external runtime dependency. Ванильный upstream-бинарник, который оркестратор забирает из официального источника во время установки.
- HY2XS admin — native HY2XS component. Исходный код лежит в репозитории, компонент собирается production builder'ом вместе с остальными артефактами HY2XS.
Это противопоставление и есть основная архитектурная граница.
Состав компонента
- исходный код admin-компонента хранится в
apps/; - backend реализован на Go;
- frontend реализован на Vue/Vite;
- frontend-ассеты встраиваются в Go-бинарник;
- компонент собирается production builder'ом вместе с остальными артефактами HY2XS;
- готовый бинарник едет в install package как
ui/hy2xs-admin/hy2xs-admin.
Правила поставки
HY2XS admin:
- поставляется внутри итогового пакета;
- имеет свой install dir;
- имеет свой data dir;
- запускается отдельным rootless systemd unit (
hy2xs-admin); - не требует target-side build.
Target server не собирает admin-компонент из исходного кода и не скачивает его из внешнего репозитория.
Scope панели
Панель нужна для:
- operator-facing управления;
- просмотра статуса;
- работы с пользователями / трафиком / сущностями доступа;
- удобной админской рутины.
Панель не должна:
- определять install lifecycle сервера;
- превращать систему в сложный control plane;
- диктовать scope оркестратора.
HY2XS admin работает как надстройка над Hysteria YAML/API-слоем. Это нормально: важно только, чтобы источник истины по runtime-состоянию был понятен и не было двух конкурирующих конфигурационных миров без правил синхронизации.
Относительно конфигурации Hysteria панель read-only: конфиг генерирует оркестратор.
Сетевая идентичность панели принадлежит оркестратору
Панель не конфигурирует себя сама.
| Величина | Источник |
|---|---|
| Порт панели | HY2XS_UI_PORT → ExecStart … -p <port> |
| Адрес привязки | HY2XS_UI_BIND_HOST из /etc/hy2xs/hy2xs.env |
| Каталог данных | HY2XS_DATA_DIR |
| Каталог логов | HY2XS_LOG_DIR |
| Маршрут панели | всегда / |
| TLS | терминируется снаружи (SSH-туннель или reverse proxy) |
До v1 эти величины дублировались в таблице config собственными ключами
панели: оркестратор передавал порт аргументом, панель записывала его в SQLite и
тут же читала обратно, а UI показывал поля в disabled-виде. Ни одного факта база
при этом не добавляла — это был второй источник истины без содержания.
В v1 таких ключей нет ни в схеме, ни в seed, ни в интерфейсе. Собственного
TLS-слоя у панели тоже нет: production-контракт — HY2XS_UI_BIND_HOST=127.0.0.1
и HY2XS_UI_PUBLIC_ACCESS=false, то есть внутренний сервис. Если панели
когда-нибудь понадобится публичный endpoint, TLS обязан заканчиваться на
ingress/reverse-proxy, а не возвращаться к модели «панель публикует себя сама».
Имена ключей предыдущего поколения намеренно не приводятся: в обычных v1-доках их словаря нет. Всё, что нужно для распознавания и удаления старой установки, — в 14-legacy-cleanup.md.
Пространства имён HTTP API
| Префикс | Назначение | Middleware |
|---|---|---|
/healthz |
liveness/readiness | нет |
/internal/hysteria/auth |
machine-to-machine: Hysteria спрашивает разрешение на подключение пира | LocalOnly + MachineAuth |
/api/... |
операторский и auth API | rate limiter, JWT, admin |
Разделение отражает разницу в природе маршрутов. /internal/hysteria/auth —
не интерфейс для человека и не часть операторского API: это внутренний
IPC-подобный HTTP endpoint между двумя процессами на одной машине. До v1 он
лежал под тем же префиксом, что и JWT-защищённый админский API, хотя
middleware у них не пересекаются.
Путь machine-auth — runtime-контракт продукта: он записывается в
/etc/hysteria/config.yaml и в post-install.env. Поэтому он объявлен ровно
в двух местах — constant.HysteriaMachineAuthPath в админке и
HYSTERIA_MACHINE_AUTH_PATH в оркестраторе, — а сборка сверяет их между собой
и с шаблонами.
Журнал запросов не содержит значений query-параметров
Hysteria обращается к машинному endpoint'у как
/internal/hysteria/auth?access_token=<machine token> — при каждом подключении
пира. Поэтому в журнале админки пишется путь, а не RequestURI:
{ "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 выполняется в три прохода:
- проверка партии целиком — права на ключ, дубликаты ключей, значения;
- одна транзакция базы;
- применение к рантайму.
Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал. Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на втором — оператор получал отказ на запрос, который систему уже изменил.
Тест на этот случай существовал, но ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен по построению.
Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы
проверяется явно: 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
Это важное архитектурное разделение.
| Слой | Назначение | Поведение при неизвестных полях |
|---|---|---|
| Типизированная модель | значения профиля для панели и генерация клиентских ссылок | неизвестные поля не отображаются |
| Сырой YAML | экспорт, а также перечень секций верхнего уровня | неизвестные поля сохраняются |
Причина: если бы экспорт работал через типизированную модель (Unmarshal → структура → Marshal), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
Поэтому:
- экспорт читает исходный YAML и сохраняет структуру документа целиком;
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
Третий слой: проекция на production-профиль
Панель показывает то, что записано в файле, — и это отдельный слой, а не типизированная модель целиком.
/etc/hysteria/config.yaml
↓
типизированная модель (значения) + сырой YAML (ключи верхнего уровня)
↓
BuildHysteria2Profile()
↓
Hysteria2ProfileVo: секции профиля + список расхождений
↓
страница (только чтение)
Что было. Ответ отдавал внутреннюю модель серверного конфига целиком, а
frontend накладывал его на полный объект значений по умолчанию
(DeepRequired + merge). В результате экран отвечал не на тот вопрос:
вопрос, который решает оператор:
что реально написано в /etc/hysteria/config.yaml?
вопрос, на который отвечал экран:
как выглядел бы конфиг, если недостающие куски заполнить дефолтами UI?
Разница не косметическая:
| в файле | показывалось | чем это плохо |
|---|---|---|
секции trafficStats нет |
listen: :9999 |
скрыта причина отказа всего контура доступа |
speedTest: false |
вкладка спрятана как «не задано» | явное значение выдано за отсутствие |
disableUDP: false |
то же | то же |
ignoreClientBandwidth: true без блока bandwidth |
не показано вовсе | опция влияет на сервер и невидима |
masquerade.string.statusCode (200..599) |
переключатель | тип не соответствует upstream |
ни tls, ни acme |
дефолты ACME | выдуманная конфигурация выпуска сертификата |
То есть экран, существующий ради диагностики расхождений, эти расхождения скрывал.
Что показывается теперь. Секции production-профиля — те, которыми действительно управляет оркестратор:
listen · auth · tls|acme · obfs · bandwidth · ignoreClientBandwidth
congestion · quic · trafficStats
Отсутствие секции остаётся отсутствием: null означает «в файле этого нет», и
подменять его дефолтом нельзя — false, 0 и пустая строка являются законными
значениями и должны быть от него отличимы.
Всё остальное попадает в расхождение конфигурации — список секций верхнего
уровня вне профиля (masquerade, resolver, sniff, acl, outbounds,
mimic, realm, а также любая секция, о которой HY2XS ещё не знает). Он
считается по сырому YAML, а не по типизированной модели: секция, которую модель
не понимает, обязана быть замечена именно как расхождение, а не потеряна при
разборе. Список секций профиля в Go и whitelist оркестратора сверяются гейтом
приёмки — два источника истины разъехались бы молча.
Почему не универсальный редактор Hysteria. Продуктом является один профиль:
конфиг генерирует оркестратор и сам же проверяет соответствие установленного
файла профилю (assertHysteriaConfigMatchesProfile). Панель конфиг не пишет —
маршрутов записи в API нет. Достраивать её до редактора всех возможностей
upstream значит поддерживать вторую, никем не применяемую модель продукта. Для
полного документа есть санитизированная выгрузка, которая сохраняет и
неизвестные поля.
Читающий экран не щедрее выгрузки
Прежний ответ вёз в браузер секреты. auth и trafficStats.secret были закрыты
json:"-", а пароль обфускации, токены ACME DNS (acme.dns.config), учётные
данные outbound-прокси и masquerade.proxy.url — нет. Скачиваемый экспорт того
же конфига их вырезает; привилегий это не повышало (маршрут под admin JWT), но
read-only экрану эти значения не нужны вовсе.
Теперь вместо значения показывается диагностически достаточный факт:
| поле | что видно оператору |
|---|---|
obfs.*.password |
«задан» / «не задан»; сам пароль выдаётся в клиентской ссылке пира |
trafficStats.secret |
«задан» / «не задан» |
acme.dns.config |
имена параметров без значений |
auth.http.url |
адрес с вырезанным access_token (тем же санитайзером, что и выгрузка) |
Страница Hysteria — только чтение, и теперь это верно на всех уровнях
Страница прямо сообщает, что конфигом владеет hy2xs-orchestrator reconfigure.
Маршрутов записи серверного конфига в API нет — они удалены вместе с мёртвым
updater/config-write слоем.
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
передавались в них без v-model, то есть у их событий update:* не было ни
одного слушателя. Оператор мог добавить outbound, увидеть его в списке и уйти в
уверенности, что изменил конфигурацию сервера; изменения не переживали даже
переключения вкладки. У одного из них цена была ещё и измеримой: редактор списка
значений работал на vuedraggable, которая тянула в bundle полную сборку Vue с
рантайм-компилятором шаблонов — около полумегабайта ради перетаскивания тегов в
недоступной для редактирования форме.
Сначала все три были приведены к отображению, а вместе с переходом на проекцию профиля удалены целиком вместе со своими компонентами: пока «универсальный редактор» существует в дереве, он отрастает заново.
Санитайз экспорта
Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
- пароли обфускации (
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.
Правило доступа объявлено один раз
Пускать пира или нет — решает одна функция, 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 |
Смена секрета меняет и идентичность сессий
Контракт:
peer.id — постоянная идентичность записи
secret — учётные данные
auth_id — идентичность ПОКОЛЕНИЯ живых Hysteria-сессий
Новый секрет получает новый auth_id; /kick при этом идёт по старому —
именно им Hysteria знает отзываемую сессию. Правило действует на обеих дверях к
смене учётных данных: и в форме панели, и в импорте.
Ротация происходит тогда и только тогда, когда меняется secret_digest.
Повторная отправка того же секрета — это повторная попытка отзыва (она рвёт
сессию снова, как и повторное «Отключить»), но нового поколения credentials не
создаёт, поэтому идентичность сессий не трогает.
Зачем это нужно. Отзыв секрета состоит из двух шагов, и второй умеет не
удаться — сходимость обязан обеспечить cron. Но пока auth_id оставался
прежним, сверять было нечем: сессия, установленная по отозванному секрету,
называлась тем же значением, пир в базе существовал, доступ был открыт,
устройств не больше разрешённого. Признака «установлена по уже недействительным
учётным данным» в системе не существовало вовсе.
Хуже того, у этого состояния есть путь без единой неудачи. Ответ авторизации
и регистрация соединения в Traffic Stats API — не одна транзакция: Hysteria
сначала дожидается Authenticate, и только после ok = true помечает
соединение аутентифицированным и сообщает о нём Traffic Stats API. Значит:
1. клиент со старым секретом начинает авторизацию,
Hysteria2Auth читает пира и ждёт ответа GET /online
2. оператор меняет секрет: запись прошла, /kick вернул 200
3. задержанная авторизация возвращает ALLOW со СТАРЫМ authId
4. Hysteria регистрирует сессию — уже после kick'а
Все шаги успешны, а сессия по отозванному секрету жива. Атомарной пары «решение авторизации + регистрация онлайна» upstream API не даёт, поэтому повторным чтением базы перед ответом это окно не закрыть — оно сдвинется, но останется.
Ротация auth_id закрывает оба случая одним уже существующим механизмом:
пережившая сессия называется значением, которого в базе больше нет, и очередной
цикл учёта видит её как orphan (см. «Сверка живых сессий»). Ни отдельной таблицы
отозванных поколений, ни очереди повторов для этого не заводится.
Цена названа прямо: до следующего цикла учёта такая сессия считается сессией
неизвестного пира, поэтому её дельта трафика приписывается некому и попадает в
потери цикла. Это не более 30 секунд трафика одного пира на одну ротацию —
осознанный размен, о котором см. «Учёт трафика — операционная граница, а не
биллинг». Колонка «прежний auth_id» ради этих секунд ввела бы второй
идентификатор сессии, то есть ровно то состояние, из-за которого отзыв и не
сходился.
Правило асимметрично намеренно: ограничение применяется немедленно, послабление — нет. Увеличенная квота, продлённый срок, поднятый лимит устройств, правка имени или пометки сессию не рвут — у оператора нет причины ронять работающее соединение, расширяя пиру права.
Все они идут через один 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, повтор разорвёт ЕГО
смена секрета НЕ работает: в базе уже новый digest, старая сессия
неотличима от законной
Три нижние строки не имели механизма схождения вовсе. Первые две закрывает cron
— см. «Сверка живых сессий» ниже; третью — ротация auth_id, после которой она
сводится к первым двум: старое поколение становится orphan. Отдельной таблицы retry, очереди отложенных
операций и хранимого «списка того, что не удалось разорвать» для этого не
нужно: /online и есть список живых сессий.
Формулировка сообщения не называет конкретную операцию: через этот код отчитываются все восемь строк таблицы выше, а для удалённого пира фраза «новые подключения пира запрещены» была бы просто бессмысленной.
Отключение и временная блокировка — разные механизмы, и смешивать их нельзя:
| снимается | назначение | |
|---|---|---|
disabled |
только руками оператора | отзыв доступа |
banned_until |
истекает сам | временная блокировка |
Поэтому disconnectAuthIDs не пишет в базу вовсе и не читает её: он принимает
готовые authId. Включение пира не сбрасывает banned_until, а снятие
блокировки не включает отключённого пира.
Состояние службы по systemd в этом пути не участвует. Оно годится только для отображения и не является основанием ни для отказа операции, ни для её пропуска — ни здесь, ни в cron. Ответ даёт само обращение к Traffic Stats API. Как именно читается состояние службы и почему у него три значения, а не два — см. «Состояние службы и доступность 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.
Состояние службы и доступность API — разные факты
Путь отображения тоже строгий, и это исправление, а не ужесточение ради симметрии.
Что было. Общий Hysteria2Online начинался с ярлыка «служба неактивна по
мнению systemd → пустая карта, ошибки нет». Пустая карта БЕЗ ошибки неотличима
от «никто не подключён», поэтому сборщик метрик выставлял apiReachable = true,
ни разу не обратившись к Traffic Stats API, а список пиров показывал всех
офлайн. Дашборд умел утверждать одновременно:
Hysteria остановлена
Traffic Stats API доступен
онлайн: 0
— три утверждения об одной системе, из которых первые два несовместимы, и все
три получены из одного ответа systemctl.
Источник неопределённости. util.Exec выбрасывает вывод команды, как только
код возврата не нулевой, а systemctl is-active отвечает словом состояния в
stdout ВМЕСТЕ с кодом 3. Прочитать это слово было нечем, поэтому «служба
неактивна» и «спросить не получилось» приходили в панель одним значением
false.
Как теперь. Два источника отвечают на два вопроса, и ни один не выводится из другого:
| источник | значения |
|---|---|
systemctl is-active через util.ExecProbe |
active · inactive · unknown |
| фактическое обращение к Traffic Stats API | доступен · недоступен |
unknown — это не «остановлена». Дашборд показывает для него предупреждение
«состояние службы неизвестно», а не критическую плашку «служба остановлена»: у
этих двух состояний разные действия оператора, и второе отправляло его
перезапускать работающий туннель.
Список пиров при недоступном API отвечает onlineState: unavailable — один
признак на страницу, а не nullable-флаг в каждой строке, — и показывает «онлайн
неизвестен» вместо «офлайн». Число подключённых устройств в этом состоянии
показывается как ?: ноль был бы утверждением, которого никто не проверял.
Решения о доступе на этих значениях по-прежнему не строятся: авторизация и cron спрашивают Traffic Stats API напрямую.
Лимит выдерживает параллельные подключения
Сравнения ответа /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_ENABLEDHY2XS_ADMIN_SOURCEHY2XS_ADMIN_BUILD_IDHY2XS_ADMIN_BIND_HOSTHY2XS_ADMIN_PORTHY2XS_ADMIN_INSTALL_DIRHY2XS_ADMIN_DATA_DIRHY2XS_ADMIN_LOG_DIR
Учётные данные и аутентификация
Bootstrap-учётные данные приходят от оркестратора
Первая учётная запись администратора создаётся из HY2XS_ADMIN_INITIAL_PASSWORD,
пир установщика — из HY2XS_ADMIN_CON_PASS. Оба значения задаёт оркестратор
через /etc/hy2xs/hy2xs.env, а копию кладёт в
/etc/hy2xs/bootstrap-admin.secret.
Если переменной нет, а создавать учётную запись нужно, админка отказывает в старте с сообщением, называющим причину и способ починки.
То же и при значении вне контракта пароля: HY2XS_ADMIN_INITIAL_PASSWORD
проверяется против того же правила, что и форма входа
(apps/credential/admin.go), и непригодное значение роняет старт с внятным
текстом, а не доходит до bcrypt.GenerateFromPassword, чтобы вернуться оттуда
строкой password length exceeds 72 bytes. Учётная запись при этом не
создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя.
Пароль читается как есть: пробелы по краям объявлены его частью и не
снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap
делал strings.TrimSpace, и учётная запись создавалась не с тем паролем,
который оператор записал в hy2xs.env.
Раньше она в этом случае придумывала пароль сама и печатала его двумя
logrus.Warnf — открытым текстом в /var/log/hy2xs/hy2xs-admin.log, то есть в
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
утечки, у такого пароля была вторая проблема: его не знал никто, кроме журнала.
Попадание в эту ветку означает не «нужно что-то придумать», а повреждённый
контракт запуска, и реакция на него должна быть громкой.
Для пира установщика цена ошибки ещё конкретнее: его секрет продублирован в
bootstrap-admin.secret, откуда его читает проверка machine-auth в smoke
оркестратора. Придуманный админкой секрет разошёлся бы с файлом, и проверка
подключения провалилась бы на корректном во всём остальном сервере.
Порядок проверок при этом такой: сначала выясняется, нужно ли вообще создавать запись, и только потом требуется переменная. Перезапуск уже установленного сервиса без неё работает штатно.
Тот же принцип распространён на machine token HYSTERIA2_TRAFFIC_STATS_SECRET.
Раньше при пустом env и пустой базе админка генерировала его сама, и это было
хуже, чем отказ: записать значение в /etc/hysteria/config.yaml она не может —
файл принадлежит оркестратору и доступен ей только на чтение, что проверяет
smoke. Результат — сервис объявлял себя здоровым, а machine auth переставал
совпадать, потому что Hysteria продолжала слать прежний токен. Допустимых
состояний три:
| env | база | поведение |
|---|---|---|
| задан | любое | база синхронизируется с env: владелец значения — оркестратор |
| пуст | токен есть | рабочее состояние, ничего не меняется |
| пуст | пусто | отказ старта |
Вторая строка нужна для ручного systemctl start без EnvironmentFile: она не
изобретает контракт, а использует уже согласованный.
Пир установщика защищён во всех путях записи
bootstrap-admin-peer нельзя переименовать, переподписать или занять его имя
чужим пиром — ни импортом, ни через обычные формы панели. Раньше проверка стояла
только в импорте, то есть ровно то, ради чего она существует, делалось через
интерфейс.
Удаление и отключение при этом разрешены: после установки это обычный действующий доступ, секрет которого лежит ещё и в файле на диске, и оператор обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке.
Отзыв пира установщика необратим
Разрешать удаление имеет смысл только вместе с этим свойством, иначе панель предлагает операцию, которой не выполняет.
Признаком «создавать пир или нет» служит отметка BOOTSTRAP_PEER_SEEDED в
таблице config. Она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не
«существует сейчас», и выставляется той же транзакцией, которой создаётся сам
пир.
Раньше признаком было наличие строки в таблице пиров, и отзыв доступа не переживал перезапуск сервиса:
оператор удаляет bootstrap-admin-peer
↓
доступ действительно исчезает
systemctl restart hy2xs-admin (или reboot)
↓
InitSql → ensureSecureBootstrapPeer
↓
строки нет → прочитать HY2XS_ADMIN_CON_PASS из /etc/hy2xs/hy2xs.env
↓
создать пира заново → ТОТ ЖЕ секрет снова действует
Переменная никуда не девается из hy2xs.env — её читает systemd-юнит, — поэтому
восстановление происходило молча: ни строки в журнале, а в списке пиров
запись просто снова есть. Отзыв учётных данных, который не переживает restart,
отзывом не является.
Транзакционность здесь не формальность: раздельная запись вернула бы прежнее поведение в новой форме, потому что падение процесса между созданием пира и записью отметки снова дало бы следующему старту «ещё не создавался».
Что при этом происходит с файлом на диске: /etc/hy2xs/bootstrap-admin.secret
принадлежит оркестратору, админка его не трогает, и после отзыва он содержит уже
недействующее значение. Это ожидаемо — файл является копией того, что установка
записала в базу, а не источником истины для рантайма.
Отключение (Disabled = 1) остаётся вторым, обратимым способом: Hysteria2Auth
выбирает пира с условием disabled = 0, поэтому доступ закрывается сразу, а
запись сохраняется.
Жизненный цикл закреплён тестами в apps/dao/bootstrap_peer_test.go: создание,
перезапуск без изменений, удаление с последующими перезапусками, отключение,
отказ старта без HY2XS_ADMIN_CON_PASS на чистой базе и успешный перезапуск без
неё на установленной.
Токены и пароли
Токены выписываются и проверяются golang-jwt/jwt/v5. Переход с v3 —
не косметика: у github.com/golang-jwt/jwt v3.2.2 есть GO-2025-3553, у которой
нет исправленной версии в ветке v3 (Fixed in: N/A), а уязвимый код
достигается из разбора токена, то есть с неаутентифицированного запроса.
Обновлять было нечего — лечится только сменой мажорной ветки.
Заодно закрыт тихий недостаток прежней реализации: keyfunc возвращал ключ,
не проверяя алгоритм подписи, то есть набор допустимых алгоритмов
фактически задавал сам токен. Сейчас разбор ограничен jwt.WithValidMethods,
проверяются issuer и обязательное наличие срока жизни, а пустой JWT_SECRET
считается повреждённым состоянием, а не ключом нулевой длины.
Пароли администратора хранятся ровно в одном формате — bcrypt. Ветка сравнения
с несолёным SHA-224 (формат предыдущего поколения) удалена: в v1 такой хеш не
может появиться — миграции таблицы account удалены, установка возможна только
на чистый хост, а конфигурация 0.x отклоняется по HY2XS_CONFIG_SCHEMA_VERSION.
Compatibility-ветка пережила слой совместимости, ради которого существовала, и
осталась запасным путём проверки пароля слабым алгоритмом в обработчике логина.
Все секреты продукта генерируются одним примитивом util.RandomString с
отбраковкой (rejection sampling): прежняя реализация брала остаток байта от
деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали
примерно на четверть чаще остальных.
Инварианты
Схема считается корректной, если:
- HY2XS admin приезжает на target уже в составе пакета
- target не скачивает и не собирает admin-компонент
- HY2XS admin работает отдельным сервисом
- HY2XS admin не меняет install-only scope оркестратора
- Hysteria2 остаётся внешним vanilla upstream-компонентом
- HY2XS admin не выступает updater-менеджером Hysteria2
trafficStats.secretне связан сJWT_SECRET- экспорт конфига сохраняет неизвестные upstream-поля
- экспорт конфига не содержит секретов
- сгенерированная
hysteria2://ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую - планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер
- значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет
- партия настроек применяется целиком или не применяется вовсе
- в таблице
configнет ключей без потребителя - bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
- любой журнал, покидающий сервер, проходит санитайз
- пароль администратора хранится ровно в одном формате — bcrypt
- правило доступа объявлено ровно один раз (
peerAccessDenied), и авторизация с принудительным отключением спрашивают именно его - каждая операция, способная сделать живую сессию устаревшей, проходит через
один
reconcileLiveSessions, а он — через единственный вход к/kick - долговременное состояние записывается ДО разрыва, и неудача разрыва его не откатывает
- удаление пира завершает его сессию до того, как исчезнет
authId - импорт разрывает старые сессии после
COMMITи по старымauthId - джоба учёта выполняется синхронно, и
StopCron()её дожидается - лимит устройств не превышается параллельными запросами авторизации — в том
числе когда снимки
/onlineприходят в обратном порядке - цикл учёта сверяет КАЖДУЮ живую сессию из
/online, а не только тех пиров, которых удалось найти в базе; отказ базы при этом не рвёт ничего - неудавшийся разрыв не оставляет систему в несогласованном состоянии
навсегда: сессия удалённого либо переподписанного пира и превышение
maxDevicesустраняются очередным циклом учёта - смена секрета меняет
auth_id, поэтому сессия, установленная по отозванным учётным данным, становится orphan и завершается очередным циклом учёта — в том числе когда/kickпрошёл успешно, но соединение зарегистрировалось после него auth_idгенерируется ровно одним способом (newPeerAuthID), и ротация происходит тогда и только тогда, когда меняетсяsecret_digest- адрес Traffic Stats API — внутренний контракт: оркестратор допускает только
127.0.0.1, а админка называет расхождение вместо молчаливой подстановки loopback - состояние службы по systemd имеет три значения, и
unknownне выдаётся за «остановлена»; доступность Traffic Stats API — независимый факт, полученный фактическим обращением - отказ Traffic Stats API отображается как «состояние неизвестно», а не как «все пиры офлайн»
- журнал Hysteria разбирается в фактическом формате upstream (числовое
time) и сохраняет структурный контекст записи - страница конфигурации показывает записанные значения без синтетических дефолтов, отдельно перечисляет секции вне production-профиля и не отдаёт браузеру секретов
- секреты не покидают сервер и через YAML-якоря: санитайзер выгрузки следует по ссылкам