fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
# Admin panel: HY2XS admin
|
||||
|
||||
> Контракты панели, закреплённые тестами и релизными гейтами — отрисовка
|
||||
> иконок, структурированные ошибки, необязательные поля и генерация секретов,
|
||||
> атрибуция — вынесены в отдельный документ:
|
||||
> [15-ui-contracts.md](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/`](../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](../operations/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`:
|
||||
|
||||
```json
|
||||
{ "reqMethod": "POST", "reqPath": "/internal/hysteria/auth", "reqQueryKeys": "access_token" }
|
||||
```
|
||||
|
||||
Пока логировался `RequestURI`, действующий machine token оседал открытым
|
||||
текстом в `/var/log/hy2xs/hy2xs-admin.log`. Этот файл отдаётся оператору через
|
||||
`ExportLog` и попадает в diagnostics-бандл, то есть секрет утекал наружу в
|
||||
штатном режиме работы — мимо всей структурной редакции, сделанной для конфигов
|
||||
и env.
|
||||
|
||||
Значения query-параметров не логируются вовсе: список «что можно» пришлось бы
|
||||
вести вручную, и он неизбежно разошёлся бы с набором маршрутов. Имена
|
||||
параметров сохранены — для диагностики их достаточно.
|
||||
|
||||
Каналов журналирования у панели ровно один. Админка запускается через
|
||||
`gin.New()` + `gin.Recovery()`, а не `gin.Default()`: штатный `gin.Logger()`
|
||||
печатает путь **вместе с query string** в stdout, откуда он уходит в journald, а
|
||||
оттуда — в diagnostics-бандл. Это был второй, независимый канал той же утечки, и
|
||||
починка собственного логгера его бы не закрыла.
|
||||
|
||||
Журнал Hysteria (`ExportLog`, вкладка логов) проходит через санитайз
|
||||
`service.SanitizeLogText`: `HY2_AUTH_URL` несёт `access_token`, и upstream волен
|
||||
упомянуть его в сообщении об ошибке обращения к auth-backend. Санитайз
|
||||
сохраняет host, port и path — диагностика от него не страдает. Тот же проход
|
||||
применяется к `journal-*.log` внутри diagnostics-бандла оркестратора.
|
||||
|
||||
**Собственный журнал админки проходит тот же санитайз.** Раньше не проходил: он
|
||||
отдавался сырым файлом через `c.File(constant.SystemLogPath)` и показывался во
|
||||
вкладке без обработки. Асимметрия «чужому журналу не доверяем, своему доверяем»
|
||||
ничем не обоснована — файл в обоих случаях покидает сервер и пересылается в
|
||||
переписке, — и цена у неё была известна поимённо: пока bootstrap-пароль
|
||||
администратора печатался в лог warning'ом, обычная кнопка выгрузки отдавала его
|
||||
открытым текстом. Сам warning убран (см. ниже), но защита стоит и на выходе:
|
||||
следующий неосторожный `logrus.Warnf("token=%s", …)` не превратит выгрузку
|
||||
журнала в канал утечки.
|
||||
|
||||
## Импорт и экспорт
|
||||
|
||||
| Операция | Статус |
|
||||
| --- | --- |
|
||||
| Экспорт пиров (`POST /api/peer-export`) | есть, в двух режимах |
|
||||
| Импорт пиров (`POST /api/peer-import`) | есть |
|
||||
| Экспорт конфига Hysteria (`POST /api/config/exportHysteria2Config`) | есть, с вырезанием секретов |
|
||||
| Экспорт/импорт таблицы `config` | **удалён** |
|
||||
|
||||
Generic-выгрузка таблицы `config` отдавала её целиком, исключая только сырой
|
||||
Hysteria YAML. В той же таблице лежат `JWT_SECRET`, `PEER_SECRET_KEY`,
|
||||
`PEER_SECRET_ENCRYPTION_KEY` и `HYSTERIA2_TRAFFIC_STATS_SECRET`: кнопка
|
||||
«Export» выгружала их в открытом виде, а зеркальный импорт позволял их
|
||||
подменить. Для `PEER_SECRET_ENCRYPTION_KEY` это не только вопрос секретности —
|
||||
после подмены перестают расшифровываться секреты уже существующих пиров.
|
||||
|
||||
Осмысленного production-сценария у этой пары не было: конфигурацией сервера
|
||||
владеет оркестратор, перенос пиров делают `peer-import`/`peer-export`.
|
||||
|
||||
### Точечный доступ к таблице `config` — по allowlist
|
||||
|
||||
Удаления generic-пары оказалось недостаточно. Опасность осталась в точечном
|
||||
API: `getConfig` и `listConfig` принимали произвольный ключ, а проверка записи
|
||||
работала denylist'ом из трёх ключей оркестратора. То есть авторизованный запрос
|
||||
`?key=PEER_SECRET_ENCRYPTION_KEY` отдавал master-key шифрования секретов пиров,
|
||||
а `updateConfigs` позволял подменить `JWT_SECRET` и оба peer-ключа. Отверстие
|
||||
сменило размер, но не исчезло.
|
||||
|
||||
В v1:
|
||||
|
||||
| Ключ | Чтение | Запись |
|
||||
| --- | --- | --- |
|
||||
| `RESET_TRAFFIC_CRON` | да | да |
|
||||
| `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор |
|
||||
| `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет |
|
||||
| любой другой | нет | нет |
|
||||
|
||||
Список — **allowlist**, и это структурное решение, а не стилистическое.
|
||||
Denylist требует, чтобы автор каждого нового ключа вспомнил про этот файл:
|
||||
забытый ключ при denylist сразу публичен, при allowlist — сразу закрыт. Отказ
|
||||
по умолчанию не зависит от внимательности.
|
||||
|
||||
Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не
|
||||
было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь.
|
||||
|
||||
### Мёртвое состояние в таблице `config` удалено
|
||||
|
||||
Четыре ключа предыдущего поколения к v1 перестали чем-либо управлять и удалены
|
||||
вместе со строками в базе (миграция `006_drop_dead_config_keys`):
|
||||
|
||||
| Ключ | Что с ним было не так |
|
||||
| --- | --- |
|
||||
| `HYSTERIA2_ENABLE` | жизненным циклом Hysteria владеет systemd; единственным потребителем ключа была строка в журнале |
|
||||
| `HYSTERIA2_CONFIG` | второй источник истины рядом с `/etc/hysteria/config.yaml`, причём читался **первым**: значение, попавшее в базу в обход продукта, молча становилось тем, что панель показывает и из чего генерирует ссылки |
|
||||
| `HYSTERIA2_TRAFFIC_TIME` | настройка «период учёта трафика» без единого потребителя в рантайме: интервал сбора метрик задан в коде |
|
||||
| `HYSTERIA2_CONFIG_REMARK` | пустая read-only строка, которую никто никогда не записывал |
|
||||
|
||||
Настоящую замену получил только последний: имя профиля в клиентской ссылке
|
||||
теперь выводится из **имени пира**, а при его отсутствии — из публичного хоста.
|
||||
Это то различие, которое пользователю и нужно видеть в списке серверов, и оно не
|
||||
требует ни одной дополнительной настройки.
|
||||
|
||||
После очистки панель владеет ровно одной настройкой — `RESET_TRAFFIC_CRON`, — а
|
||||
всё остальное в таблице является внутренними секретами.
|
||||
|
||||
### Расписание сброса трафика: планировщик принадлежит процессу
|
||||
|
||||
Смена `RESET_TRAFFIC_CRON` **не перезапускает** HTTP-сервер.
|
||||
|
||||
Раньше перезапускала: обработчик вызывал `StopServer()`, точка входа крутила
|
||||
`for { runServer() }` и поднимала сервис заново, чтобы новый планировщик прочитал
|
||||
настройку из базы. При этом `InitCron()` на каждом вызове создавал новый
|
||||
`cron.New()` и нигде не сохранял ссылку, а `cron.Stop()` не вызывался нигде.
|
||||
Итог: каждая правка расписания добавляла **целый дублирующий набор джоб**, а
|
||||
старое расписание сброса продолжало работать. После двух правок на процессе
|
||||
висели три планировщика и три разных расписания одновременно.
|
||||
|
||||
Теперь фиксированные джобы регистрируются один раз за жизнь процесса, а
|
||||
расписание сброса переносится на месте по своему `EntryID`.
|
||||
|
||||
Выражение проверяется **до записи в базу** тем же парсером
|
||||
(`cron.ParseStandard`), которым его потом разбирает планировщик. Невалидное
|
||||
значение — отказ `4xx`, база не меняется. Раньше строка сохранялась, API отвечал
|
||||
успехом, а сброс трафика молча исчезал до следующего чтения журнала.
|
||||
|
||||
Пустое значение легально и означает «автоматический сброс выключен».
|
||||
|
||||
Сервис завершается штатно по `SIGTERM`: сначала останавливается планировщик и
|
||||
дожидаются запущенные джобы, затем закрывается SQLite. Обратный порядок означал
|
||||
бы работу джоб с уже закрытым соединением при каждом `systemctl restart`.
|
||||
|
||||
Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, —
|
||||
«этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет»,
|
||||
и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
|
||||
|
||||
Оба оставшихся экспорта формируются **в памяти** и отдаются прямо в ответ.
|
||||
Раньше они шли через `os.Create` в `/var/lib/hy2xs-admin/export/`, и файл там
|
||||
оставался навсегда — при `?includeSecrets=true` это означало расшифрованные
|
||||
секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога
|
||||
`export/` больше не существует.
|
||||
|
||||
### Партия настроек применяется целиком или не применяется вовсе
|
||||
|
||||
`POST /api/config/updateConfigs` выполняется в три прохода:
|
||||
|
||||
1. проверка партии целиком — права на ключ, дубликаты ключей, значения;
|
||||
2. одна транзакция базы;
|
||||
3. применение к рантайму.
|
||||
|
||||
Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал.
|
||||
Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на
|
||||
втором — оператор получал отказ на запрос, который систему уже изменил.
|
||||
|
||||
Тест на этот случай существовал, но ставил запрещённый ключ **первым** и не
|
||||
смотрел в базу — поймать частичное применение он был неспособен по построению.
|
||||
Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы
|
||||
проверяется явно: `TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden`.
|
||||
|
||||
### Экспорт пиров: два режима, а не флаг
|
||||
|
||||
| Кнопка | Запрос | Что внутри |
|
||||
| --- | --- | --- |
|
||||
| **Экспорт настроек** | `POST /api/peer-export` | список пиров без секретов |
|
||||
| **Резервная копия** | `POST /api/peer-export?includeSecrets=true` | то же плюс действующие секреты подключения |
|
||||
|
||||
Разница здесь продуктовая, а не техническая, и её нельзя оставлять неявной.
|
||||
Записи с пустым секретом при импорте получают **новые** секреты. То есть
|
||||
перенос обычным экспортом восстанавливает пиров, их квоты, лимиты и счётчики —
|
||||
но все существующие клиентские ссылки после него перестают работать.
|
||||
|
||||
Раньше кнопка в панели была одна и всегда звала маршрут без `includeSecrets`,
|
||||
хотя документация называла эту пару механизмом переноса пиров. Оператор
|
||||
переносил пиров и обнаруживал, что все клиенты отвалились.
|
||||
|
||||
Резервная копия содержит фактические учётные данные доступа к VPN в открытом
|
||||
виде, поэтому запускается только через явное подтверждение с описанием риска.
|
||||
Такой файл следует хранить как пароль и удалять после завершения переноса.
|
||||
|
||||
#### Резервная копия либо полная, либо её нет
|
||||
|
||||
Для `includeSecrets=true` правило строгое: если секрет хотя бы одного пира
|
||||
получить не удалось — расшифровка не прошла или шифртекста нет вовсе — **весь**
|
||||
запрос завершается ошибкой, называющей проблемного пира, и файл не создаётся.
|
||||
|
||||
Раньше оба этих случая обрабатывались молча: пир уезжал в файл с пустым полем
|
||||
`secret`, а запрос отвечал успехом. Оператор получал файл, выглядящий полным:
|
||||
|
||||
```json
|
||||
[{"name":"A","secret":"..."},
|
||||
{"name":"B","secret":""},
|
||||
{"name":"C","secret":"..."}]
|
||||
```
|
||||
|
||||
Обнаруживалось это уже после импорта на новом сервере: B получал новый
|
||||
сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима ровно в
|
||||
том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
|
||||
частичный результат под этим именем — худший из возможных ответов.
|
||||
|
||||
Безопасная выгрузка (`includeSecrets=false`) шифртекст не трогает вовсе и
|
||||
повреждённых данных не замечает: пустой `secret` там — не потеря, а весь смысл
|
||||
режима.
|
||||
|
||||
Секреты пиров хранятся только зашифрованными, в единственном формате `v1:` +
|
||||
AES-GCM. Значение без этого префикса — не «формат предыдущего поколения», а
|
||||
повреждённые данные, и расшифровка на них отказывает. Прежняя реализация
|
||||
возвращала такое содержимое как якобы успешно расшифрованный секрет, то есть
|
||||
мусор из колонки уходил и в клиентскую ссылку, и в резервную копию.
|
||||
|
||||
### Импорт пиров
|
||||
|
||||
Импорт проверяется так же строго, как обычное создание пира: те же правила для
|
||||
имени, quota, `maxDevices`, `disabled`, длины секрета. Дополнительно:
|
||||
|
||||
- неизвестные поля в JSON отклоняются, а не игнорируются молча;
|
||||
- файл обязан содержать **ровно один** JSON-документ. `json.Decoder` читает
|
||||
первый документ и останавливается, поэтому файл с хвостом принимался целиком,
|
||||
а его вторая половина молча не применялась;
|
||||
- партия проверяется целиком **до** первой записи в базу;
|
||||
- применение идёт **одной транзакцией**;
|
||||
- пир `bootstrap-admin-peer` защищён от перезаписи: его секрет продублирован
|
||||
в `/etc/hy2xs/bootstrap-admin.secret`.
|
||||
|
||||
Транзакция — не дублирование проверки, а закрытие другого класса отказов.
|
||||
Валидация проверяет содержимое файла и ничего не знает о том, что уже лежит в
|
||||
базе. Пусть существуют `A(auth_id=aaa, name=alice1)` и
|
||||
`B(auth_id=bbb, name=bob123)`, а файл несёт `(auth_id=aaa, name=bob123)`: поиск
|
||||
найдёт A по `auth_id` и попытается переименовать её в `bob123` — прямо в
|
||||
`UNIQUE(name)`. Пока записи применялись по одной, всё, что шло в файле до
|
||||
конфликтной строки, оставалось применённым, и откатить это оператор уже не мог.
|
||||
|
||||
Криптоматериал (digest и шифртекст секретов) считается **до** открытия
|
||||
транзакции: эти операции читают ключи из той же таблицы `config`, и держать на
|
||||
ней открытую запись во время AES по каждой из тысяч записей незачем.
|
||||
|
||||
## Два слоя работы с конфигом Hysteria
|
||||
|
||||
Это важное архитектурное разделение.
|
||||
|
||||
| Слой | Назначение | Поведение при неизвестных полях |
|
||||
| --- | --- | --- |
|
||||
| Типизированная модель | отображение известных HY2XS полей в UI | неизвестные поля не отображаются |
|
||||
| Сырой YAML | экспорт и сохранение | неизвестные поля **сохраняются** |
|
||||
|
||||
Причина: если бы экспорт работал через типизированную модель (`Unmarshal` → структура → `Marshal`), то любое поле, о котором HY2XS ещё не знает, терялось бы при round-trip. Панель незаметно урезала бы современный конфиг.
|
||||
|
||||
Поэтому:
|
||||
|
||||
- экспорт читает исходный YAML и сохраняет структуру документа целиком;
|
||||
- будущие версии Hysteria не ломают экспорт только потому, что backend и frontend ещё не научились показывать новый параметр;
|
||||
- это прямое следствие модели «latest stable на сборке»: схема upstream может опережать модель HY2XS.
|
||||
|
||||
### Третий слой: модель отображения
|
||||
|
||||
У типизированной модели есть подслой, о котором стоит сказать отдельно, потому
|
||||
что он определяет, как устроены шаблоны страницы Hysteria.
|
||||
|
||||
`Hysteria2ServerConfig` описывает то, что **приходит по сети**, и почти все его
|
||||
секции необязательны — ровно так же, как в upstream YAML. Форма же обращается к
|
||||
ним напрямую: `dataForm.tls.cert`, `dataForm.acme.dns.config`,
|
||||
`dataForm.resolver.https.sni`.
|
||||
|
||||
Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
|
||||
Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` — и он прав: обращение
|
||||
через возможно отсутствующий объект падает в рантайме. Спасало то, что форма
|
||||
строится merge'ем поверх полного объекта значений по умолчанию, то есть
|
||||
инвариант «секция есть всегда» существовал, но держался на порядке присваиваний
|
||||
внутри компонента и нигде не был выражен типом.
|
||||
|
||||
Закрыто одним преобразованием на границе, а не 141 оператором `?.` и не
|
||||
`as any`:
|
||||
|
||||
```text
|
||||
ответ API (Hysteria2ServerConfig, секции необязательны)
|
||||
↓
|
||||
normalizeHysteriaViewModel()
|
||||
↓
|
||||
Hysteria2ServerConfigView — все секции обязательны
|
||||
↓
|
||||
шаблон
|
||||
```
|
||||
|
||||
`Hysteria2ServerConfigView` выводится из `Hysteria2ServerConfig` типом, а не
|
||||
пишется вторым списком полей. Поэтому новая секция в схеме ломает компиляцию на
|
||||
объекте значений по умолчанию — то есть поле upstream нельзя молча не
|
||||
отобразить.
|
||||
|
||||
Побочное следствие: `v-if` в шаблоне перестали проверять присутствие секции и
|
||||
проверяют только то, что действительно определяет выбор ветки. Например для
|
||||
обфускации это `dataForm.obfs.type === 'gecko'` вместо
|
||||
`dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko` — вторая половина
|
||||
дублировала первую и существовала только из-за необязательности типа.
|
||||
|
||||
Этот слой не участвует в экспорте: выгрузка идёт от исходного YAML и сохраняет
|
||||
неизвестные поля, поэтому их потеря в модели отображения безвредна.
|
||||
|
||||
### Страница Hysteria — только чтение, и теперь это верно на всех уровнях
|
||||
|
||||
Страница отрисована с `:disabled="true"` и прямо сообщает, что конфигом владеет
|
||||
`hy2xs-orchestrator reconfigure`. Маршрутов записи серверного конфига в API нет
|
||||
— они удалены вместе с мёртвым updater/config-write слоем.
|
||||
|
||||
Тем не менее на ней жили три полноценных редактора: outbounds (кнопка «+»,
|
||||
диалог создания, удаление), список значений (перетаскивание тегов, добавление,
|
||||
удаление) и словарь «ключ — значение». Ни один не мог ничего сохранить: значения
|
||||
передаются в них как `:outbounds=`, `:tags=`, `:map-object=` — без `v-model`,
|
||||
то есть у их событий `update:*` нет ни одного слушателя. Оператор мог добавить
|
||||
outbound, увидеть его в списке и уйти в уверенности, что изменил конфигурацию
|
||||
сервера; изменения не переживали даже переключения вкладки.
|
||||
|
||||
Все три приведены к отображению. У одного из них цена была ещё и измеримой:
|
||||
редактор списка значений работал на `vuedraggable`, которая поставляется
|
||||
UMD-сборкой, поэтому её `require("vue")` разрешался в полную сборку Vue вместе с
|
||||
рантайм-компилятором шаблонов — около полумегабайта в bundle ради
|
||||
перетаскивания тегов в недоступной для редактирования форме.
|
||||
|
||||
### Санитайз экспорта
|
||||
|
||||
Экспортируемый файл покидает сервер, поэтому секреты из него вырезаются:
|
||||
|
||||
- пароли обфускации (`obfs.*.password`);
|
||||
- `trafficStats.secret`;
|
||||
- `access_token` в auth-URL и учётные данные, встроенные в URL;
|
||||
- `auth.password`, `auth.userpass`;
|
||||
- учётные данные ACME DNS-провайдера;
|
||||
- **неизвестные** поля с секретоподобным именем: `password`, `passwd`,
|
||||
`passphrase`, `secret`, `token`, `credential`, `apiKey` / `api_key`,
|
||||
`privateKey` / `private_key`, `accessKey`, `secretKey`, `authorization`,
|
||||
`cookie`, `bearer`, `signature`.
|
||||
|
||||
Последний пункт — обратная сторона сохранения неизвестных полей: новое
|
||||
upstream-поле с секретом вырезается ещё до того, как HY2XS про него узнает.
|
||||
|
||||
### Как формулируется гарантия
|
||||
|
||||
Точная формулировка:
|
||||
|
||||
> вырезаются известные секреты и неизвестные поля с секретоподобным именем.
|
||||
|
||||
Не «любой будущий секрет будет автоматически удалён». Обобщённый sanitizer
|
||||
работает по именам полей и не может предугадать произвольное имя, которое
|
||||
upstream выберет для нового секрета. Список маркеров синхронизирован с
|
||||
`orchestrator/src/lib/redaction.ts`; при появлении нового поля его нужно
|
||||
добавить в оба места.
|
||||
|
||||
Пути к файлам (`tls.cert`, `tls.key`, `ech.keyPath`, `tls.clientCA`) секретами
|
||||
не считаются и остаются читаемыми — они нужны для диагностики.
|
||||
|
||||
## Модель современной схемы Hysteria
|
||||
|
||||
Модель админки понимает актуальную серверную схему, даже там, где UI не позволяет ничего включить: `obfs.gecko`, `ech`, `congestion`, `mimic`, `realm`, `tls.clientCA`, `quic.disableStatelessReset`, `bandwidth.disableLossCompensation`, `masquerade.proxy.xForwarded`.
|
||||
|
||||
Смысл в том, чтобы admin **понимал текущую upstream-схему**, а не считал неизвестными поля собственного конфига.
|
||||
|
||||
## Генерация клиентских ссылок
|
||||
|
||||
- тип обфускации и пароль берутся из фактического конфига одинаково для всех поддерживаемых типов (`gecko`, `salamander`);
|
||||
- неизвестный тип обфускации в ссылку не попадает: лучше отсутствие параметра, чем параметр, который клиент не понимает;
|
||||
- публичный endpoint берётся из `HY2XS_PUBLIC_HOST` + `HY2XS_PUBLIC_PORT`, а не из `listen` или Host-заголовка запроса;
|
||||
- SNI берётся из ACME-домена, затем из `HY2XS_DOMAIN`, затем из `HY2XS_PUBLIC_HOST`; IP-адрес как SNI не используется;
|
||||
- `minPacketSize`/`maxPacketSize` Gecko в ссылку не помещаются — поэтому HY2XS держит их на upstream-defaults `512/1200`.
|
||||
|
||||
## Правила ответственности
|
||||
|
||||
### Source of truth
|
||||
|
||||
- runtime transport layer: Hysteria2
|
||||
- операторский UI layer: HY2XS admin
|
||||
- install lifecycle: оркестратор HY2XS
|
||||
- deploy facts: `post-install.env`
|
||||
|
||||
## Production lifecycle Hysteria2
|
||||
|
||||
В production package HY2XS admin не скачивает и не обновляет бинарь Hysteria2 самостоятельно.
|
||||
|
||||
Правильная модель:
|
||||
|
||||
- Hysteria2 устанавливается install-оркестратором с official upstream;
|
||||
- Hysteria2 запускается отдельным `hysteria-server.service`;
|
||||
- HY2XS admin работает как operator UI и HTTP auth/traffic layer;
|
||||
- HY2XS admin не запускается от root;
|
||||
- смена версии Hysteria2 через UI **отсутствует как API**;
|
||||
- список upstream releases не является частью operator UI baseline;
|
||||
- port hopping не является частью production path.
|
||||
|
||||
### Удалённые операции: почему не заглушки
|
||||
|
||||
Маршруты, которые продукт принципиально не поддерживает, **удалены**, а не
|
||||
оставлены отвечающими «feature disabled»:
|
||||
|
||||
| Удалённый маршрут | Кто владеет операцией |
|
||||
| --- | --- |
|
||||
| `POST /hysteria2ChangeVersion` | install-оркестратор |
|
||||
| `GET /listRelease` | build layer |
|
||||
| `POST /config/updateHysteria2Config` | install-оркестратор |
|
||||
| `POST /config/importHysteria2Config` | install-оркестратор |
|
||||
| `POST /config/restartServer` | systemd |
|
||||
| `POST /config/uploadCertFile` | оператор + оркестратор |
|
||||
| `GET /config/hysteria2AcmePath` | не имел потребителя |
|
||||
| `POST /config/exportConfig` | выгружал JWT- и peer-ключи в открытом виде |
|
||||
| `POST /config/importConfig` | позволял подменить те же ключи |
|
||||
|
||||
Причины две.
|
||||
|
||||
Во-первых, API-контракт не должен даже обещать updater, которого у продукта
|
||||
нет: маршрут, всегда возвращающий отказ, вводит в заблуждение.
|
||||
|
||||
Во-вторых, это лишняя attack surface и технический мусор от прежней
|
||||
архитектуры.
|
||||
|
||||
Вместе с маршрутами удалены соответствующие клиентские функции фронтенда,
|
||||
кнопки и строки i18n. Кнопка, которая гарантированно возвращает ошибку, —
|
||||
не «точка расширения на будущее», а дефект UX. Возвращение любого из этих
|
||||
маршрутов ломает acceptance-проверку сборки.
|
||||
|
||||
Конфигурация Hysteria остаётся доступной панели **на чтение и на выгрузку**:
|
||||
`GET /config/getHysteria2Config` и `POST /config/exportHysteria2Config`.
|
||||
|
||||
### Что нельзя делать
|
||||
|
||||
- собирать admin-компонент на target server;
|
||||
- скачивать admin-компонент на target из внешнего репозитория;
|
||||
- склеивать unit Hysteria2 и unit HY2XS admin в один сервис;
|
||||
- раздувать оркестратор из-за особенностей панели;
|
||||
- использовать HY2XS admin как updater бинаря Hysteria2;
|
||||
- использовать `JWT_SECRET` как `trafficStats.secret` для Hysteria API;
|
||||
- экспортировать конфиг Hysteria через типизированную модель — так теряются неизвестные upstream-поля;
|
||||
- выгружать конфиг с секретами в открытом виде.
|
||||
|
||||
## Что фиксировать в `post-install.env`
|
||||
|
||||
Минимум:
|
||||
|
||||
- `HY2XS_ADMIN_ENABLED`
|
||||
- `HY2XS_ADMIN_SOURCE`
|
||||
- `HY2XS_ADMIN_BUILD_ID`
|
||||
- `HY2XS_ADMIN_BIND_HOST`
|
||||
- `HY2XS_ADMIN_PORT`
|
||||
- `HY2XS_ADMIN_INSTALL_DIR`
|
||||
- `HY2XS_ADMIN_DATA_DIR`
|
||||
- `HY2XS_ADMIN_LOG_DIR`
|
||||
|
||||
## Учётные данные и аутентификация
|
||||
|
||||
### Bootstrap-учётные данные приходят от оркестратора
|
||||
|
||||
Первая учётная запись администратора создаётся из `HY2XS_ADMIN_INITIAL_PASSWORD`,
|
||||
пир установщика — из `HY2XS_ADMIN_CON_PASS`. Оба значения задаёт оркестратор
|
||||
через `/etc/hy2xs/hy2xs.env`, а копию кладёт в
|
||||
`/etc/hy2xs/bootstrap-admin.secret`.
|
||||
|
||||
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
|
||||
старте** с сообщением, называющим причину и способ починки.
|
||||
|
||||
Раньше она в этом случае придумывала пароль сама и печатала его двумя
|
||||
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
|
||||
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
|
||||
утечки, у такого пароля была вторая проблема: его не знал никто, кроме журнала.
|
||||
Попадание в эту ветку означает не «нужно что-то придумать», а повреждённый
|
||||
контракт запуска, и реакция на него должна быть громкой.
|
||||
|
||||
Для пира установщика цена ошибки ещё конкретнее: его секрет продублирован в
|
||||
`bootstrap-admin.secret`, откуда его читает проверка machine-auth в smoke
|
||||
оркестратора. Придуманный админкой секрет разошёлся бы с файлом, и проверка
|
||||
подключения провалилась бы на корректном во всём остальном сервере.
|
||||
|
||||
Порядок проверок при этом такой: сначала выясняется, нужно ли вообще создавать
|
||||
запись, и только потом требуется переменная. Перезапуск уже установленного
|
||||
сервиса без неё работает штатно.
|
||||
|
||||
Тот же принцип распространён на machine token `HYSTERIA2_TRAFFIC_STATS_SECRET`.
|
||||
Раньше при пустом env и пустой базе админка генерировала его сама, и это было
|
||||
хуже, чем отказ: записать значение в `/etc/hysteria/config.yaml` она не может —
|
||||
файл принадлежит оркестратору и доступен ей только на чтение, что проверяет
|
||||
smoke. Результат — сервис объявлял себя здоровым, а machine auth переставал
|
||||
совпадать, потому что Hysteria продолжала слать прежний токен. Допустимых
|
||||
состояний три:
|
||||
|
||||
| env | база | поведение |
|
||||
| --- | --- | --- |
|
||||
| задан | любое | база синхронизируется с env: владелец значения — оркестратор |
|
||||
| пуст | токен есть | рабочее состояние, ничего не меняется |
|
||||
| пуст | пусто | **отказ старта** |
|
||||
|
||||
Вторая строка нужна для ручного `systemctl start` без `EnvironmentFile`: она не
|
||||
изобретает контракт, а использует уже согласованный.
|
||||
|
||||
### Пир установщика защищён во всех путях записи
|
||||
|
||||
`bootstrap-admin-peer` нельзя переименовать, переподписать или занять его имя
|
||||
чужим пиром — ни импортом, ни через обычные формы панели. Раньше проверка стояла
|
||||
только в импорте, то есть ровно то, ради чего она существует, делалось через
|
||||
интерфейс.
|
||||
|
||||
Удаление и отключение при этом **разрешены**: после установки это обычный
|
||||
действующий доступ, секрет которого лежит ещё и в файле на диске, и оператор
|
||||
обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не
|
||||
создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке.
|
||||
|
||||
### Отзыв пира установщика необратим
|
||||
|
||||
Разрешать удаление имеет смысл только вместе с этим свойством, иначе панель
|
||||
предлагает операцию, которой не выполняет.
|
||||
|
||||
Признаком «создавать пир или нет» служит отметка `BOOTSTRAP_PEER_SEEDED` в
|
||||
таблице `config`. Она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не
|
||||
«существует сейчас», и выставляется той же транзакцией, которой создаётся сам
|
||||
пир.
|
||||
|
||||
Раньше признаком было наличие строки в таблице пиров, и отзыв доступа не
|
||||
переживал перезапуск сервиса:
|
||||
|
||||
```text
|
||||
оператор удаляет bootstrap-admin-peer
|
||||
↓
|
||||
доступ действительно исчезает
|
||||
|
||||
systemctl restart hy2xs-admin (или reboot)
|
||||
↓
|
||||
InitSql → ensureSecureBootstrapPeer
|
||||
↓
|
||||
строки нет → прочитать HY2XS_ADMIN_CON_PASS из /etc/hy2xs/hy2xs.env
|
||||
↓
|
||||
создать пира заново → ТОТ ЖЕ секрет снова действует
|
||||
```
|
||||
|
||||
Переменная никуда не девается из `hy2xs.env` — её читает systemd-юнит, — поэтому
|
||||
восстановление происходило **молча**: ни строки в журнале, а в списке пиров
|
||||
запись просто снова есть. Отзыв учётных данных, который не переживает restart,
|
||||
отзывом не является.
|
||||
|
||||
Транзакционность здесь не формальность: раздельная запись вернула бы прежнее
|
||||
поведение в новой форме, потому что падение процесса между созданием пира и
|
||||
записью отметки снова дало бы следующему старту «ещё не создавался».
|
||||
|
||||
Что при этом происходит с файлом на диске: `/etc/hy2xs/bootstrap-admin.secret`
|
||||
принадлежит оркестратору, админка его не трогает, и после отзыва он содержит уже
|
||||
недействующее значение. Это ожидаемо — файл является копией того, что установка
|
||||
записала в базу, а не источником истины для рантайма.
|
||||
|
||||
Отключение (`Disabled = 1`) остаётся вторым, обратимым способом: `Hysteria2Auth`
|
||||
выбирает пира с условием `disabled = 0`, поэтому доступ закрывается сразу, а
|
||||
запись сохраняется.
|
||||
|
||||
Жизненный цикл закреплён тестами в `apps/dao/bootstrap_peer_test.go`: создание,
|
||||
перезапуск без изменений, удаление с последующими перезапусками, отключение,
|
||||
отказ старта без `HY2XS_ADMIN_CON_PASS` на чистой базе и успешный перезапуск без
|
||||
неё на установленной.
|
||||
|
||||
### Токены и пароли
|
||||
|
||||
Токены выписываются и проверяются `golang-jwt/jwt/v5`. Переход с v3 —
|
||||
не косметика: у `github.com/golang-jwt/jwt` v3.2.2 есть GO-2025-3553, у которой
|
||||
**нет исправленной версии в ветке v3** (`Fixed in: N/A`), а уязвимый код
|
||||
достигается из разбора токена, то есть с неаутентифицированного запроса.
|
||||
Обновлять было нечего — лечится только сменой мажорной ветки.
|
||||
|
||||
Заодно закрыт тихий недостаток прежней реализации: `keyfunc` возвращал ключ,
|
||||
**не проверяя алгоритм подписи**, то есть набор допустимых алгоритмов
|
||||
фактически задавал сам токен. Сейчас разбор ограничен `jwt.WithValidMethods`,
|
||||
проверяются `issuer` и обязательное наличие срока жизни, а пустой `JWT_SECRET`
|
||||
считается повреждённым состоянием, а не ключом нулевой длины.
|
||||
|
||||
Пароли администратора хранятся ровно в одном формате — bcrypt. Ветка сравнения
|
||||
с несолёным SHA-224 (формат предыдущего поколения) удалена: в v1 такой хеш не
|
||||
может появиться — миграции таблицы `account` удалены, установка возможна только
|
||||
на чистый хост, а конфигурация 0.x отклоняется по `HY2XS_CONFIG_SCHEMA_VERSION`.
|
||||
Compatibility-ветка пережила слой совместимости, ради которого существовала, и
|
||||
осталась запасным путём проверки пароля слабым алгоритмом в обработчике логина.
|
||||
|
||||
Все секреты продукта генерируются одним примитивом `util.RandomString` с
|
||||
отбраковкой (rejection sampling): прежняя реализация брала остаток байта от
|
||||
деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали
|
||||
примерно на четверть чаще остальных.
|
||||
|
||||
## Инварианты
|
||||
|
||||
Схема считается корректной, если:
|
||||
|
||||
1. HY2XS admin приезжает на target уже в составе пакета
|
||||
2. target не скачивает и не собирает admin-компонент
|
||||
3. HY2XS admin работает отдельным сервисом
|
||||
4. HY2XS admin не меняет install-only scope оркестратора
|
||||
5. Hysteria2 остаётся внешним vanilla upstream-компонентом
|
||||
6. HY2XS admin не выступает updater-менеджером Hysteria2
|
||||
7. `trafficStats.secret` не связан с `JWT_SECRET`
|
||||
8. экспорт конфига сохраняет неизвестные upstream-поля
|
||||
9. экспорт конфига не содержит секретов
|
||||
10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
|
||||
11. планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер
|
||||
12. значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет
|
||||
13. партия настроек применяется целиком или не применяется вовсе
|
||||
14. в таблице `config` нет ключей без потребителя
|
||||
15. bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
|
||||
16. любой журнал, покидающий сервер, проходит санитайз
|
||||
17. пароль администратора хранится ровно в одном формате — bcrypt
|
||||
@@ -0,0 +1,142 @@
|
||||
# Контракты панели
|
||||
|
||||
Три свойства 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. Атрибуция
|
||||
|
||||
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
||||
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
|
||||
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
|
||||
могут переопределить.
|
||||
Reference in New Issue
Block a user