a8407cf16b
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с таким именем не регистрировалось: при переименовании в `credentialStr` правка не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery превращал панику в HTTP 500 на каждый POST /api/auth/login. Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам регексп, в обход валидатора, а обработчика входа не касался ни один тест. Очевидная замена не помогла бы: цепочка правил поля обрывается на первом несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его валидатору отдельно — незарегистрированное правило паникует так же, как в бою, но на сборке. Барьер проверен возвратом исходного тега. Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба идёт в любом режиме операции и от актуальности пароля не зависит. Рядом лежали три расхождения того же класса, найденные при разборе. Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая установка проходила целиком, создавая учётную запись, под которой невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба значения теперь проверяются при разборе окружения — той стороной, которая их порождает: отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход на готовом сервере — переустановкой. Панель была строже сервера. Форма входа ограничивала пароль 32 символами при серверном пределе в 64, а форма смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных объявлен один раз в service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. Класс символов логина был записан диапазоном по опечатке: неэкранированный дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным набором это совпадало только потому, что обе стороны несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на установленных серверах. Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх, проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед «глазом». Индикация перенесена на элемент, который оператор и видит полем; чужая тень гасится селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, — конкретностью, а не !important. Остальные формы панели проверены: собственная рамка на el-form-item есть только на форме входа. Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому — UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. Обработчик входа переехал из controller/peer.go в controller/auth.go: стек в journal указывал на управление пирами. Требование теперь называется, а не сообщается фактом нарушения. «Неверный формат логина» и «Некорректное значение» не давали оператору способа узнать, что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не показан. Фразы форм и серверная причина credential_format перечисляют границы и набор. Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления — по той же причине, что и гейт детектора гонок. Каждое из его утверждений проверено мутационной пробой на реальный отказ; две первые редакции оказались вакуумными и переписаны. Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это релизный гейт сборщика. Прогон задокументирован в docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
349 lines
23 KiB
Markdown
349 lines
23 KiB
Markdown
# Контракты панели
|
||
|
||
Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
|
||
потому ломались молча. Каждое из них закреплено тестом
|
||
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
|
||
|
||
Общее описание панели — [04-admin-panel.md](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. Структурированные ошибки
|
||
|
||
**Правило.** Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю
|
||
— ещё и имя поля.
|
||
|
||
Форма ответа:
|
||
|
||
```json
|
||
{
|
||
"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`:
|
||
|
||
```ts
|
||
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/service/admin_credentials.go`:
|
||
|
||
| Что | Значение | Владелец |
|
||
| --- | --- | --- |
|
||
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
|
||
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
|
||
| Длина пароля | 6-64 символа | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
|
||
| Набор символов пароля | не ограничен | — |
|
||
|
||
Остальные три стороны продукта только повторяют этот контракт, и каждая копия
|
||
сверяется с оригиналом тестом, читающим 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` в `apps/controller/validator.go`, длина
|
||
живёт ВНУТРИ него.
|
||
|
||
### Почему у пароля нет набора символов
|
||
|
||
Пароль назначает оператор — установкой через `HY2XS_ADMIN_INITIAL_PASSWORD` или
|
||
формой смены. Сервер его набор не проверяет нигде: значение сравнивается с
|
||
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
|
||
отвергнуть пароль, который сервер принял бы.
|
||
|
||
Верхняя граница в 64 символа выбрана не круглым числом: bcrypt читает первые 72
|
||
БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо ниже.
|
||
|
||
Длина считается в **символах**, а не в байтах: `go-playground/validator` считает
|
||
`min`/`max` на строке в рунах, и проверка по байтам отвергла бы пароль из 32
|
||
кириллических букв, который сервер принимает.
|
||
|
||
### Границы обеих форм обязаны совпадать
|
||
|
||
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
|
||
требование. Расхождение здесь запирает оператора снаружи после операции,
|
||
которую панель ему же и предложила: пароль длиннее предела формы входа
|
||
назначается успешно и после этого не вводится.
|
||
|
||
### Индикация ошибки принадлежит видимому полю
|
||
|
||
Element Plus рисует состояние отказа на `el-input__wrapper` правилом
|
||
|
||
```text
|
||
.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.passwordLength` перечисляют
|
||
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
|
||
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
|
||
давало оператору ни одного способа узнать, что от него хотят.
|
||
|
||
Серверная причина `credential_format` несёт те же значения в `params`
|
||
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
|
||
и проверяет и длину, и набор, поэтому описывать его только через символы —
|
||
значит описывать отказ по длине неверно.
|