31 KiB
Контракты панели
Свойства 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 |
| Домен пароля | документированный домен systemd EnvironmentFile=: валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters |
IsEnvTransportableText |
| Набор символов пароля | не ограничен, кроме Cc |
hasForbiddenRune |
| Пробелы по краям пароля | часть значения, не снимаются | — |
Контракт живёт в отдельном 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-хешем. Ограничение набора на форме не защищает ничего и умеет только
отвергнуть пароль, который сервер принял бы.
Исключения два, и они разного происхождения. Их важно не путать: одно описывает чужое ограничение, другое — наше решение.
Домен systemd — не наше правило. Первый пароль администратора уезжает в
/etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile=. Перед тем
как принять пару, systemd прогоняет ключ и значение через utf8_is_valid
(src/basic/env-file.c, check_utf8ness_and_warn), и отказ там возвращает
-EINVAL: это незагруженный файл окружения, то есть юнит, который не
стартует, а не предупреждение. unichar_is_valid (src/basic/utf8.c)
отвергает:
U+D800..U+DFFF суррогаты
U+FDD0..U+FDEF noncharacters
(cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
плюс встроенный NUL, U+FEFF и любую невалидную последовательность UTF-8.
U+FEFF запрещён публичной документацией EnvironmentFile. Реализация systemd
v257.13 случайно пропускает его (0xFEFF & 0xFFFE == 0xFEFE); HY2XS следует
документированному контракту, а не закрепляет ошибку конкретной версии.
Пока контракт этого не знал, пароль abcde + U+FDD0 — шесть символов, восемь
байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование,
записывался в hy2xs.env, и после этого админка не поднималась. Тот же класс
дефекта, ради уничтожения которого контракт и существует, только слоем ниже.
На стороне панели и оркестратора отдельно отвергаются одиночные суррогаты:
строка JavaScript вправе их содержать, а TextEncoder молча заменит непарный
суррогат на U+FFFD. Без этой проверки не было бы отказа — было бы тихое
изменение пароля по дороге в файл.
Политика HY2XS — наше решение. Сверх транспортного домена запрещены управляющие
символы Unicode целиком (категория Cc: U+0000..U+001F, U+007F,
U+0080..U+009F). Их невозможно ни увидеть в
поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть
оператора снаружи.
На HTTP-границе проверяется не только уже декодированная Go-строка. Сырые JSON
байты должны быть валидным UTF-8, а \uXXXX — не содержать непарных UTF-16
суррогатов. Это делается до encoding/json, который иначе молча заменил бы оба
дефекта на допустимый U+FFFD и мог бы аутентифицировать другое значение.
Почему границ у пароля две
Их две потому, что они в разных единицах, и вывести одну из другой нельзя.
Граница в символах — та, которую видит оператор. Она считается в 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 символов» отправило бы оператора сокращать пароль, отвергнутый
не за это.