Files
HY2XS_flamy/docs/admin/15-ui-contracts.md
T
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 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>
2026-09-06 18:38:04 +05:00

28 KiB
Raw Blame History

Контракты панели

Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и потому ломались молча. Каждое из них закреплено тестом (tools/test/frontend-*.test.ts) и гейтом приёмки.

Общее описание панели — 04-admin-panel.md.


1. Отрисовка иконок

Правило. Монохромная UI-иконка получает цвет ровно одним способом — наследованием currentColor от компонента и темы. Ассет не содержит литеральных цветов; цвет объявляется в CSS один раз, на .svg-icon.

Запрещено:

  • литеральный fill / stroke / stop-color в монохромном ассете;
  • цвет в инлайновом style внутри ассета;
  • непустой <style> внутри ассета — его селекторы глобальны и красят чужие иконки;
  • растровое <image> — оно не подчиняется currentColor никогда;
  • CSS-фильтр на .svg-icon;
  • селектор по имени конкретной иконки ([icon-class="…"]);
  • передача цвета параметром компонента.

Многоцветные ассеты объявляются явным списком MULTICOLOR_ICONS в SvgIcon/symbol.ts. Их палитра — часть ассета, и проверка цвета к ним не применяется. «Многоцветность» обязана быть решением, а не следствием того, что иконку скачали с готовыми значениями fill.

Система координат. У каждого ассета обязан быть viewBox либо пара width/height, из которой он синтезируется. Без неё <use> рисует иконку в натуральную величину и обрезает её по размеру родительского <svg>.

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

Что машина не докажет. Фактический цвет на экране. Визуальная проверка светлой и тёмной темы, состояний hover/active и свёрнутого меню остаётся ручной и фиксируется в отчёте приёмки.


2. Структурированные ошибки

Правило. Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю — ещё и имя поля.

Форма ответа:

{
  "code": 50001,
  "type": "no",
  "message": "проверка данных не пройдена",
  "errors": [
    { "code": "min_length", "field": "secret", "message": "…", "params": { "min": "6" } }
  ],
  "data": null
}
  • code — числовой код ответа (model/constant/code.go);
  • errors[].code — причина (model/constant/error.go, ErrCode*);
  • errors[].field — имя поля из JSON-тега; пусто для отказов уровня операции;
  • errors[].params — числа правила, чтобы панель не заводила их вторую копию;
  • message — человекочитаемый ответ для клиента без UI и запасной вариант для кода, которого панель ещё не знает.

Границы числа и границы длины строки различаются кодом (min против min_length), хотя тег валидатора у них один: оператору это разные фразы.

Запрещено:

  • выводить код ответа сравнением текста сообщения;
  • отдавать обобщённое invalid вместо описания полей;
  • разбирать сообщение сервера на стороне панели.

Локализация строится по ключу error.code.<код> с параметрами правила. Наборы ключей ru и en обязаны совпадать: забытый ключ не ломает ни типы, ни сборку — vue-i18n молча отдаёт сам ключ, и оператор видит error.code.min_length вместо фразы.

Состояние сессии сообщается кодами unauthorized, session_expired, token_invalid, account_disabled при code = 50401. Панель по ним показывает диалог и возвращает на форму входа.

Вход администратора. Неверные учётные данные всегда дают один код invalid_credentials: «такого администратора нет» и «пароль не тот» обязаны быть неразличимы снаружи. Отказ хранилища при этом остаётся системной ошибкой — выдавать «неверный логин или пароль» при недоступной базе значит отправить оператора искать несуществующую опечатку.


3. Необязательные поля и генерация секретов

Правило. Поле, объявленное необязательным, обязано принимать три неразличимых состояния: отсутствует, пустая строка, одни пробелы.

Это НЕ следует автоматически из тега omitempty. В go-playground/validator он не пропускает правило, если поле объявлено указателем и указатель не nil — hasValue считает указатель на пустую строку «значением». Поэтому DTO, у которых есть необязательные строковые поля, реализуют dto.Normalizable, и слой контроллеров вызывает Normalize() между разбором тела и проверкой правил.

Граница проходит по каждому полю отдельно. Общее правило «пусто → не задано» молча ломает смысл:

Поле Пустое значение означает
secret при создании сгенерировать
secret при изменении не менять
name при изменении не менять
remark убрать пометку
disabled = 0 включён
quotaBytes = 0 нулевая квота

Генерация секрета принадлежит серверу. Панель, подставляющая значение в пустое поле, выполняла бы обещание «сгенерируем автоматически» ровно для одной двери из четырёх: остаются прямой вызов API, импорт и будущие клиенты. Единственная реализация — service.GeneratePeerSecret, на базе util.RandomString (crypto/rand с отбрасыванием смещённых байтов). Тот же вызов используется импортом.

Имя пира проверяется одним правилом service.IsValidPeerName на весь продукт: в таблицу пиров ведут две двери, и они не имеют права требовать разного.


4. Таблицы журнала

Правило. Колонка журнала объявляет свою ширину: служебные — через width, содержательная — через min-width.

Без этого Element Plus делит доступную ширину между колонками практически поровну. У журнала колонок три, поэтому уровень и время получали по трети строки, а сообщение — единственное содержимое журнала — тоже треть.

Сообщение переносится, а не обрезается. У Hysteria в msg приезжает диагностический JSON; строка, обрезанная многоточием, не отвечает ни на один вопрос, ради которого страницу открыли.

Обе страницы журнала построены на одном компоненте (components/LogViewer). Они были побайтово одинаковы и несли одни и те же три дефекта в двух экземплярах — ширины, обработку отказа выгрузки и форму ответа. Собственная el-table-column на странице журнала запрещена гейтом приёмки.


5. Выгрузка файлов

Правило. Сборка ссылки на скачивание существует в панели в единственном экземпляре — utils/download.ts. Единственность проверяется контрактным тестом по вхождению createObjectURL.

Копий было четыре, и все успели разойтись. Две из них ставили сетевой запрос ПЕРЕД try:

const response = await exportApi(...);   // отказ сюда не попадает
try { ... } catch (e) { /* empty */ }

то есть отказ самого запроса не ловился вовсе, а всё внутри глушилось молча: оператор не получал ни файла, ни причины. Третья падала на split(...) при отсутствующем Content-Disposition — и это исключение тоже глушилось.

Отказ выгрузки показывает сама страница. Бинарный ответ не проходит через общий разбор конверта: у Blob нет полей code и errors, поэтому перехватчик по нему фразы не даст.


6. Меню действий над строкой

Правило. Пункты el-dropdown объявляют command; обработчик — один, на el-dropdown.

@click на каждом пункте не запрещён самим Element Plus, но command является штатным контрактом именно для меню действий, и при нём невозможно добавить пункт, забыв его подключить. Обе половины проверяются контрактным тестом: наличие @command и отсутствие @click на пунктах.

Частичный результат операции отличается от отказа кодом. Отзыв доступа к VPN состоит из двух половин — записи в базе и разрыва активной сессии, — и первая может примениться без второй. Панель обязана распознать peer_disconnect_failed по коду, показать его предупреждением, а не ошибкой, и ОБНОВИТЬ строку: состояние в базе уже изменилось. Показ его как обычной ошибки подтолкнул бы оператора к выводу, прямо противоположному истине.


7. Подтверждения

Правило. Отмена подтверждения — это ответ оператора, а не ошибка.

ElMessageBox отклоняет промис при нажатии «Отмена». await ElMessageBox.confirm(...) без разбора отказа оставляет необработанное отклонение промиса на каждую отмену. Единственный прямой вызов на странице пиров живёт внутри confirmAction, переводящей отмену в обычное false; это закреплено тестом.


8. Панель не выдумывает состояние

Правило. Отсутствие данных показывается как отсутствие данных.

Нарушений было три, и все три давали оператору ответ, противоположный истине.

Страница конфигурации строила форму merge'ем ответа сервера поверх полного объекта значений по умолчанию, поэтому отсутствующая секция trafficStats показывалась как listen: :9999, а явное speedTest: false считалось ненастроенным и прятало свою вкладку. Экран, существующий ради диагностики дрейфа, этот дрейф скрывал. Теперь ответ отличает «не задано» (null) от значения, а секции вне production-профиля перечисляются отдельным списком расхождений.

Список пиров получал onlineUsers, _ := Hysteria2Online() и при любом сбое control plane показывал всех пиров офлайн. Теперь страница несёт onlineState: ok | unavailable — один признак на ответ, а не флаг в каждой строке, — и при unavailable показывает «онлайн неизвестен», а число устройств как ?.

Дашборд выводил доступность Traffic Stats API из ответа systemctl и умел утверждать «служба остановлена» и «API доступен» одновременно. Теперь это два независимых факта, а у состояния службы три значения: active, inactive, unknown.

Запрещено:

  • подставлять значение по умолчанию вместо отсутствующего в ответе;
  • показывать 0, false или «офлайн» там, где данные не получены;
  • выводить один факт из другого, если их можно спросить по отдельности.

Секреты на читающем экране. Read-only страница не имеет права быть щедрее санитизированной выгрузки того же документа. Вместо значения показывается диагностический факт: «задан» / «не задан» для паролей и секретов, имена параметров без значений для acme.dns.config, адрес с вырезанным access_token для auth-URL.

Редакторы без сохранения запрещены. Конфигом владеет оркестратор, маршрутов записи в API нет, поэтому поля ввода на странице конфигурации обещают действие, которого не существует. Проверяется контрактным тестом: в шаблоне нет ни el-input, ни el-switch, ни el-select, ни v-model.


9. Атрибуция

Адрес атрибуции объявлен один раз в apps/frontend/src/constants/branding.ts и принадлежит приложению. Он не является операторской настройкой: ни hy2xs.env, ни config API, ни таблица config, ни настройки панели его не содержат и не могут переопределить.


10. Форма входа

Правило. Панель не имеет права быть строже сервера. Значение, которое сервер принял бы, форма обязана отправить.

Где живёт контракт

Требования к логину и паролю администратора объявлены один раз, в apps/credential/admin.go:

Что Значение Владелец
Длина логина 6-32 символа AdminUsernameMinLength / AdminUsernameMaxLength
Набор символов логина a-z A-Z 0-9 !@#$%^&*()_+,-./:;<= AdminUsernameCharset
Длина пароля 6-64 символа Unicode AdminPasswordMinLength / AdminPasswordMaxLength
Размер пароля не более 72 байт в UTF-8 AdminPasswordMaxBytes
Набор символов пароля не ограничен, кроме управляющих
Пробелы по краям пароля часть значения, не снимаются

Контракт живёт в отдельном leaf-пакете, а не в service, и это не вкусовщина. Его зовут util.HashPassword и слой данных при создании первой учётной записи, а service импортирует util — обратный импорт был бы циклическим. Пока контракт лежал в service, HashPassword завёл собственную проверку len(strings.TrimSpace(password)) < 6, и она разошлась с остальным продуктом.

Остальные стороны продукта только повторяют этот контракт, и каждая копия сверяется с оригиналом тестом, читающим Go-исходник:

  • панель — apps/frontend/src/constants/credentials.ts (tools/test/frontend-contract.test.ts);
  • оркестратор — orchestrator/src/config/profile.ts (orchestrator/test/admin-credentials.test.ts);
  • правила валидатора — credentialStr и adminPassword в apps/controller/validator.go, длина живёт ВНУТРИ них.

Почему у пароля нет набора символов

Пароль назначает оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены. Сервер его набор не проверяет нигде: значение сравнивается с bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только отвергнуть пароль, который сервер принял бы.

Единственное исключение — управляющие символы (C0 и DEL). Они запрещены не формой, а транспортом: первый пароль администратора уезжает в /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile=, и у перевода строки там нет представления, переживающего запись и чтение. Такой пароль всё равно невозможно ввести в однострочное поле формы входа, то есть он умеет ровно одно — запереть оператора снаружи.

Почему границ у пароля две

Их две потому, что они в разных единицах, и вывести одну из другой нельзя.

Граница в символах — та, которую видит оператор. Она считается в code points, а не в байтах и не в единицах UTF-16: go-playground/validator считает min/max на строке через utf8.RuneCountInString, и «пароль из 64 символов» обязано означать одно и то же для латиницы и для кириллицы.

Граница в байтах — та, которую ставит bcrypt. golang.org/x/crypto/bcrypt отвечает ErrPasswordTooLong на пароль длиннее 72 байт (GenerateFromPassword, bcrypt.go:96). У 64 символов длина от 64 до 256 байт:

64 x "a"   =  64 байта  -> принимается
36 x "я"   =  72 байта  -> принимается (граница)
37 x "я"   =  74 байта  -> отвергается
18 x "😀"  =  72 байта  -> принимается (граница)
19 x "😀"  =  76 байт   -> отвергается
64 x "я"   = 128 байт   -> отвергается

Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72 байт» — верно только для ASCII, — а сопровождающий текст утверждал, что bcrypt «молча отбрасывает остаток». Так вела себя редакция пакета до v0.28; действующая отвечает ошибкой. Следствие: пароль из 64 кириллических букв проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а после установки — отсутствием администратора вовсе.

Панель считает длину так же, как сервер

Встроенных min/max Element Plus у пароля нет. Правила формы Element Plus делегирует библиотеке async-validator, а та сравнивает min/max строки с String.prototype.length, то есть считает единицы UTF-16:

"😀😀😀"   Go: 3 руны        -> сервер отказывает (минимум 6)
           JS: length === 6  -> форма считала минимум достигнутым

Панель отправляла бы заведомо отвергаемый пароль и не могла бы объяснить отказ. Поэтому у обеих форм одно общее правило adminPasswordFormRule, и оно считает code points итератором строки, а байты — через TextEncoder.

Границы обеих форм обязаны совпадать

Форма входа и форма смены пароля предъявляют к паролю одно и то же требование. Расхождение здесь запирает оператора снаружи после операции, которую панель ему же и предложила: пароль длиннее предела формы входа назначается успешно и после этого не вводится.

Границы обеих форм обязаны совпадать

Форма входа и форма смены пароля предъявляют к паролю одно и то же требование. Расхождение здесь запирает оператора снаружи после операции, которую панель ему же и предложила: пароль длиннее предела формы входа назначается успешно и после этого не вводится.

Индикация ошибки принадлежит видимому полю

Element Plus рисует состояние отказа на el-input__wrapper правилом

.el-form-item.is-error .el-form-item__content .el-input__wrapper

то есть селектором из четырёх классов. На форме входа видимое поле — это el-form-item: внутрь одного поля кладутся иконка, ввод и переключатель видимости пароля, а el-input занимает лишь среднюю часть. Поэтому штатная индикация ложится вокруг одного лишь ввода и ни одной стороной не совпадает с границей поля.

Правило. Там, где рамка поля нарисована на el-form-item, состояние отказа рисуется на нём же, а штатная тень враппера гасится селектором, который повторяет чужой и добавляет атрибут scoped-стиля — то есть выигрывает специфичностью, а не !important. Сообщению об отказе оставляется место под полем: el-form-item__error позиционируется абсолютно от top: 100% и живёт вне рамки.

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

Требование называется, а не нарушается

Фразы credentials.usernameFormat и credentials.passwordFormat перечисляют границы и набор символов. Набор логина приходит из HY2XS_ADMIN_USER, и посмотреть его в панели больше негде — сообщение «Неверный формат логина» не давало оператору ни одного способа узнать, что от него хотят.

Серверная причина credential_format несёт те же значения в params (min, max, charset), и фраза панели обязана их использовать: правило одно и проверяет и длину, и набор, поэтому описывать его только через символы — значит описывать отказ по длине неверно.

У пароля причина отдельная — admin_password_format с params (min, max, maxBytes), — и фраза обязана называть обе границы. Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72 байта: сообщение «не длиннее 64 символов» отправило бы оператора сокращать пароль, отвергнутый не за это.