618 lines
18 KiB
Plaintext
618 lines
18 KiB
Plaintext
Посмотрел архив `HY2XS_flamy-main.zip`. Проблема не косметическая: сейчас в проекте смешаны две разные доменные сущности — администратор панели и peer Hysteria2. Это видно прямо по схеме `account`: в одной таблице лежат `username/pass` для входа в админку, `con_pass` для подключения, `quota/download/upload/device_no`, `role`, `deleted`. Логин панели фильтрует `role = 'admin'`, а Hysteria2 auth ищет только по `con_pass` и не отсекает `role = 'admin'`. Поэтому админ технически остаётся peer’ом.
|
||
|
||
Ниже план нормального продакшен-исправления без «подмазать CSS и добавить if role != admin».
|
||
|
||
---
|
||
|
||
## 1. Развести админов панели и peer’ов Hysteria2
|
||
|
||
### Текущее состояние
|
||
|
||
Сейчас:
|
||
|
||
`account.pass` — пароль входа в панель.
|
||
`account.con_pass` — пароль подключения Hysteria2.
|
||
`account.role` — попытка различать `admin/user`.
|
||
`account.quota/download/upload/device_no/expire_time` — peer-поля.
|
||
`login_at` — поле админа.
|
||
`con_at` — поле peer’а.
|
||
|
||
Это плохая модель. Роль не должна превращать одну таблицу в две разные сущности.
|
||
|
||
### Целевая модель
|
||
|
||
Сделать минимум две таблицы.
|
||
|
||
`admin_user`:
|
||
|
||
```sql
|
||
id
|
||
username
|
||
password_hash
|
||
status
|
||
force_password_change
|
||
last_login_at
|
||
password_changed_at
|
||
token_version
|
||
created_at
|
||
updated_at
|
||
```
|
||
|
||
`peer`:
|
||
|
||
```sql
|
||
id
|
||
name
|
||
remark
|
||
auth_id
|
||
secret_digest
|
||
secret_ciphertext -- если нужно показывать URL/QR после создания
|
||
quota_bytes
|
||
download_bytes
|
||
upload_bytes
|
||
expires_at
|
||
max_devices
|
||
disabled
|
||
banned_until
|
||
last_connection_at
|
||
created_at
|
||
updated_at
|
||
```
|
||
|
||
`admin_user` не должен иметь `quota`, `con_pass`, `device_no`, `download`, `upload`.
|
||
|
||
`peer` не должен иметь пароль входа в панель и `role`.
|
||
|
||
### Как хранить peer secret
|
||
|
||
Текущий `con_pass` хранится как plain text. Для продакшена лучше уйти от этого.
|
||
|
||
Вариант нормальный:
|
||
|
||
`auth_id` — публичный идентификатор peer’а, например короткий random/base32.
|
||
`raw_secret` — генерируется при создании или ротации.
|
||
`secret_digest = HMAC-SHA256(raw_secret, HY2XS_PEER_SECRET_KEY)` — используется для auth lookup.
|
||
`secret_ciphertext` — опционально, если UI должен уметь повторно показать Node URL/QR. Шифровать ключом из data-dir/env, не хранить просто строкой в SQLite.
|
||
|
||
Если не хочется вводить шифрование сейчас, допустим компромисс: хранить `secret_plain` временно, но уже в таблице `peer`, не рядом с admin password. Потом отдельной миграцией заменить на digest/encrypted secret.
|
||
|
||
### Миграция
|
||
|
||
Сделать версионированные миграции, а не держать огромную строку SQL внутри `dao/sqlite.go`.
|
||
|
||
Сейчас есть два источника схемы: `apps/docs/sql/h_ui_db.sql` и inline `sqlInitStr` в `apps/dao/sqlite.go`. Они уже расходятся: в `sqlite.go` добавлен `force_password_change`, в SQL-доке его нет; `remark` объявлен как `INTEGER DEFAULT ''`, хотя в Go это `string`. Это надо убрать.
|
||
|
||
Нужен один механизм:
|
||
|
||
```sql
|
||
schema_migrations(version, applied_at)
|
||
```
|
||
|
||
Миграции:
|
||
|
||
`001_initial_legacy_snapshot.sql` — текущая схема, только для reference.
|
||
`002_admin_peer_split.sql` — создаёт `admin_user`, `peer`.
|
||
`003_migrate_legacy_accounts.sql` — переносит данные.
|
||
`004_drop_or_archive_legacy_account.sql` — не сразу удалять, а переименовать в `legacy_account_backup`.
|
||
|
||
Правила переноса:
|
||
|
||
`role = 'admin'` → `admin_user`. Переносить `username`, `pass`, `force_password_change`, `login_at`. Не переносить `con_pass`.
|
||
|
||
`role != 'admin'` → `peer`. Переносить `username` как `name`, `remark`, лимиты, трафик, `expire_time`, `kick_util_time`, `con_at`.
|
||
|
||
После миграции admin credentials больше не могут пройти Hysteria2 auth даже теоретически.
|
||
|
||
---
|
||
|
||
## 2. Переписать backend-слой по доменам
|
||
|
||
### Что заменить
|
||
|
||
Сейчас всё сидит в `account.go`: DAO, service, controller, DTO, VO. Надо разделить:
|
||
|
||
```text
|
||
dao/admin_user.go
|
||
dao/peer.go
|
||
|
||
service/auth.go
|
||
service/admin_user.go
|
||
service/peer.go
|
||
service/hysteria2_auth.go
|
||
|
||
controller/auth.go
|
||
controller/admin_user.go
|
||
controller/peer.go
|
||
controller/hysteria2.go
|
||
```
|
||
|
||
### API
|
||
|
||
Оставить `/hui/auth/login`, но он должен работать только с `admin_user`.
|
||
|
||
Добавить:
|
||
|
||
```text
|
||
GET /hui/admin/me
|
||
POST /hui/admin/change-password
|
||
|
||
GET /hui/peers
|
||
POST /hui/peers
|
||
GET /hui/peers/:id
|
||
PATCH /hui/peers/:id
|
||
DELETE /hui/peers/:id
|
||
|
||
POST /hui/peers/:id/reset-traffic
|
||
POST /hui/peers/:id/kick
|
||
POST /hui/peers/:id/release-kick
|
||
POST /hui/peers/:id/rotate-secret
|
||
GET /hui/peers/:id/client-url
|
||
GET /hui/peers/:id/qr
|
||
```
|
||
|
||
Старые `/account/*` можно оставить только как compatibility layer на один релиз, но UI уже должен ходить в `/peers/*`.
|
||
|
||
### Hysteria2 auth
|
||
|
||
Сейчас `Hysteria2Auth()` делает:
|
||
|
||
```go
|
||
dao.GetAccount("con_pass = ? and deleted = 0 ...")
|
||
```
|
||
|
||
Нужно заменить на peer-auth:
|
||
|
||
```go
|
||
peer, err := peerRepo.FindBySecretDigest(digest)
|
||
```
|
||
|
||
И проверять только peer-поля:
|
||
|
||
```text
|
||
disabled = false
|
||
now < expires_at
|
||
quota_bytes < 0 OR quota_bytes > download_bytes + upload_bytes
|
||
now > banned_until
|
||
online_devices < max_devices
|
||
```
|
||
|
||
Admin-таблица здесь вообще не импортируется.
|
||
|
||
### Сессии и JWT
|
||
|
||
JWT сейчас содержит `AccountBo` с `Roles`. Оставить можно, но лучше переименовать в `AdminClaims`.
|
||
|
||
Добавить `token_version` в `admin_user`. Тогда смена пароля, reset или принудительная инвалидизация токенов делается увеличением `token_version`.
|
||
|
||
После смены пароля обязательно сбрасывать `force_password_change = 0`. Сейчас флаг возвращается из логина, но нормального dedicated flow для смены admin password не видно.
|
||
|
||
### Reset command
|
||
|
||
`apps/cmd/reset.go` сейчас меняет row `id=1` в `account`, печатает ещё и `Connection Password`. После разделения:
|
||
|
||
```text
|
||
hy2xs-admin reset-admin
|
||
```
|
||
|
||
Должен менять только `admin_user`.
|
||
|
||
Никакого connection password для админа печатать нельзя.
|
||
|
||
---
|
||
|
||
## 3. UI: переименовать “Account Manage” в peer management
|
||
|
||
В интерфейсе сейчас название “Account” вводит в заблуждение. Там смешаны профиль админа и peer’ы. Нужна терминология:
|
||
|
||
```text
|
||
Профиль / Admin Profile
|
||
Пиры / Peers
|
||
Управление пирами / Peer Management
|
||
```
|
||
|
||
В форме создания peer убрать поле “Пароль входа”. Для peer нужен только generated/rotatable connection secret.
|
||
|
||
Нормальная форма создания peer:
|
||
|
||
```text
|
||
Комментарий
|
||
Имя / label
|
||
Квота
|
||
Срок действия
|
||
Лимит устройств
|
||
Статус
|
||
[Создать]
|
||
```
|
||
|
||
После создания показать одноразовый блок:
|
||
|
||
```text
|
||
Connection URI
|
||
QR
|
||
Copy
|
||
Сохраните сейчас: после закрытия секрет может быть скрыт
|
||
```
|
||
|
||
Если оставляете encrypted secret, можно показывать URL и позже.
|
||
|
||
---
|
||
|
||
## 4. Починить русскую локаль и “плывущие” иконки правильно
|
||
|
||
### Причина
|
||
|
||
Это не проблема русского языка как такового. Русские строки длиннее, а sidebar сейчас завязан на дефолтный layout Element Plus плюс ручные margin’ы:
|
||
|
||
```scss
|
||
.svg-icon { margin-right: 16px; }
|
||
.hideSidebar .el-sub-menu__title { padding: 0 !important; }
|
||
.hideSidebar .svg-icon { margin-left: 20px; }
|
||
```
|
||
|
||
Из-за этого при длинных заголовках, collapse/open state и sub-menu иконки начинают жить отдельно от текста.
|
||
|
||
### Исправление
|
||
|
||
В `SidebarItem.vue` обернуть title в отдельный span:
|
||
|
||
```vue
|
||
<span class="menu-title">
|
||
{{ translateRouteTitleI18n(...) }}
|
||
</span>
|
||
```
|
||
|
||
Для `el-menu-item` и `el-sub-menu__title` задать один стабильный layout:
|
||
|
||
```scss
|
||
.sidebar-container {
|
||
.el-menu-item,
|
||
.el-sub-menu__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
height: 48px;
|
||
line-height: normal;
|
||
padding: 0 16px !important;
|
||
}
|
||
|
||
.svg-icon {
|
||
flex: 0 0 18px;
|
||
width: 18px;
|
||
height: 18px;
|
||
margin-right: 0;
|
||
}
|
||
|
||
.menu-title {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.el-sub-menu__icon-arrow {
|
||
margin-left: auto;
|
||
}
|
||
}
|
||
```
|
||
|
||
Убрать ручные `margin-left: 20px` для collapsed state. Для collapsed меню текст скрывать штатно, а не через плавающие отступы.
|
||
|
||
Ширину sidebar лучше поднять с `210px` до `240px` или `248px`. Но это вторично. Основной фикс — стабильная flex/grid-разметка.
|
||
|
||
Для длинных пунктов включить tooltip с полным названием при hover. Не переносить текст на вторую строку внутри sidebar.
|
||
|
||
---
|
||
|
||
## 5. Перевести уведомления и ошибки без хардкода
|
||
|
||
Сейчас часть UI переведена через `$t`, но много строк осталось захардкоженными:
|
||
|
||
```text
|
||
"Required"
|
||
"Username format is incorrect"
|
||
"Are you sure to reset traffic?"
|
||
"Are you sure to delete..."
|
||
"Warning"
|
||
"A week later"
|
||
"file format not supported"
|
||
```
|
||
|
||
Плюс backend возвращает английские строки:
|
||
|
||
```text
|
||
wrong password
|
||
system error
|
||
permission denied
|
||
username already exists
|
||
admin cannot be deleted
|
||
```
|
||
|
||
### Frontend
|
||
|
||
Добавить namespace:
|
||
|
||
```ts
|
||
validation: {
|
||
required,
|
||
usernameInvalid,
|
||
passwordInvalid,
|
||
peerSecretInvalid,
|
||
integer,
|
||
number,
|
||
fileFormat,
|
||
fileTooLarge,
|
||
}
|
||
|
||
confirm: {
|
||
warning,
|
||
deletePeer,
|
||
resetTraffic,
|
||
restartPanel,
|
||
}
|
||
|
||
timeShortcut: {
|
||
hourLater,
|
||
dayLater,
|
||
weekLater,
|
||
monthLater,
|
||
yearLater,
|
||
}
|
||
```
|
||
|
||
Все validation rules сделать через `computed`, чтобы при смене языка сообщения обновлялись:
|
||
|
||
```ts
|
||
const dataFormRules = computed(() => ({
|
||
username: [
|
||
{ required: true, message: t("validation.required"), trigger: ["blur"] },
|
||
],
|
||
}))
|
||
```
|
||
|
||
Подключить `ElConfigProvider` в `App.vue` и прокидывать locale Element Plus:
|
||
|
||
```vue
|
||
<el-config-provider :locale="elementLocale">
|
||
<router-view />
|
||
</el-config-provider>
|
||
```
|
||
|
||
Иначе встроенные компоненты Element Plus, datepicker, pagination и popconfirm будут жить своей локалью.
|
||
|
||
### Backend
|
||
|
||
Не возвращать UI-текст как источник истины. Возвращать стабильный error code/message key:
|
||
|
||
```json
|
||
{
|
||
"code": 40010,
|
||
"type": "no",
|
||
"message": "peer.usernameAlreadyExists",
|
||
"params": { "username": "testuser" }
|
||
}
|
||
```
|
||
|
||
Frontend переводит `message` как i18n key. Если key неизвестен — fallback на `common.systemError`.
|
||
|
||
Для machine endpoint `/hysteria2/auth` локализация не нужна, там протокольный ответ `{ ok: true/false, id }`.
|
||
|
||
---
|
||
|
||
## 6. Сжать таблицу peer’ов до production-вида
|
||
|
||
Сейчас таблица перегружена. В `account/list/index.vue` одновременно выводятся:
|
||
|
||
```text
|
||
ID
|
||
Remark
|
||
Username
|
||
Role
|
||
Quota
|
||
Download
|
||
Upload
|
||
Online status
|
||
Online devices
|
||
Device limit
|
||
Offline remaining time
|
||
Expire time
|
||
Last login time
|
||
Last connection time
|
||
Create time
|
||
Status
|
||
Operate
|
||
```
|
||
|
||
Для peer list это слишком много. На широком экране оно всё равно не будет хорошо читаться.
|
||
|
||
### Основной список
|
||
|
||
Оставить в таблице только:
|
||
|
||
```text
|
||
Peer
|
||
Status
|
||
Traffic
|
||
Devices
|
||
Expires
|
||
Last connection
|
||
Actions
|
||
```
|
||
|
||
Где:
|
||
|
||
`Peer` — имя + remark + ID мелким текстом.
|
||
`Status` — enabled/disabled + online/offline.
|
||
`Traffic` — progress bar: used / quota, а upload/download спрятать в details.
|
||
`Devices` — online / max.
|
||
`Expires` — дата + “expired soon/expired” tag.
|
||
`Last connection` — дата или `-`.
|
||
`Actions` — 1–2 основные кнопки и dropdown.
|
||
|
||
### Details drawer / overview dialog
|
||
|
||
По клику “Обзор” открыть drawer:
|
||
|
||
```text
|
||
Peer overview
|
||
- ID
|
||
- Name
|
||
- Remark
|
||
- Created at
|
||
- Updated at
|
||
- Download
|
||
- Upload
|
||
- Quota
|
||
- Expires at
|
||
- Last connection
|
||
- Kick until
|
||
- Device limit
|
||
- Current online devices
|
||
- Node URL
|
||
- QR
|
||
```
|
||
|
||
Это лучше, чем прятать половину в троеточие без структуры.
|
||
|
||
### Actions
|
||
|
||
В таблице оставить:
|
||
|
||
```text
|
||
Copy URL
|
||
Edit
|
||
⋯
|
||
```
|
||
|
||
В dropdown:
|
||
|
||
```text
|
||
Show QR
|
||
Reset traffic
|
||
Kick
|
||
Release kick
|
||
Rotate secret
|
||
Disable / Enable
|
||
Delete
|
||
```
|
||
|
||
`Subscribe` сейчас в baseline отключён, значит в UI его лучше не показывать, пока delivery layer вне scope. Иначе оператор видит кнопку, которая всегда ведёт к ошибке.
|
||
|
||
### Поведение на малых экранах
|
||
|
||
На desktop можно оставить `el-table`.
|
||
|
||
На tablet/mobile лучше отдельный card layout, а не пытаться ужать таблицу. Например:
|
||
|
||
```text
|
||
Peer card
|
||
name / status
|
||
traffic progress
|
||
devices
|
||
expires
|
||
actions
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Конкретный порядок работ
|
||
|
||
### Этап A. Быстрый security hotfix
|
||
|
||
Это временный фикс, не финальная архитектура.
|
||
|
||
1. В `Hysteria2Auth` добавить фильтр `role = 'user'`.
|
||
2. В `PageAccount` по умолчанию показывать только `role = 'user'`.
|
||
3. В `SaveAccount` явно ставить `role = 'user'`.
|
||
4. Запретить выдачу Node URL/QR для `role = 'admin'`.
|
||
5. В `reset.go` убрать вывод connection password.
|
||
|
||
Это закрывает самый опасный баг до большой миграции.
|
||
|
||
### Этап B. Нормальная доменная миграция
|
||
|
||
1. Добавить `schema_migrations`.
|
||
2. Создать `admin_user`.
|
||
3. Создать `peer`.
|
||
4. Перенести legacy data.
|
||
5. Переписать DAO/service/controller.
|
||
6. Оставить legacy `account` только как backup.
|
||
7. Добавить `reset-admin`.
|
||
8. Убрать `role` из peer flow.
|
||
|
||
### Этап C. UI/i18n
|
||
|
||
1. Переименовать раздел `Account Manage` → `Peer Manage`.
|
||
2. Убрать peer login password из формы.
|
||
3. Сделать i18n keys для всех validation/confirm/toast строк.
|
||
4. Подключить Element Plus locale provider.
|
||
5. Убрать backend English strings из UI-отображения.
|
||
6. Добавить проверку отсутствующих i18n keys в CI.
|
||
|
||
### Этап D. Layout/sidebar
|
||
|
||
1. Переписать sidebar CSS на flex/grid.
|
||
2. Убрать ручные margin hacks.
|
||
3. Добавить `.menu-title` с ellipsis.
|
||
4. Поднять sidebar width до 240–248px.
|
||
5. Проверить RU/EN в expanded/collapsed состояниях.
|
||
|
||
### Этап E. Peer table redesign
|
||
|
||
1. Сделать компактные колонки.
|
||
2. Вынести details в drawer.
|
||
3. Перенести второстепенные действия в dropdown.
|
||
4. Скрыть subscription actions, если delivery layer отключён.
|
||
5. Добавить responsive card layout.
|
||
|
||
---
|
||
|
||
## 8. Тесты и критерии готовности
|
||
|
||
Backend tests:
|
||
|
||
```text
|
||
admin_user может войти в панель
|
||
admin_user не может пройти Hysteria2 auth
|
||
peer не может войти в панель
|
||
peer может пройти Hysteria2 auth
|
||
expired peer rejected
|
||
disabled peer rejected
|
||
quota-exceeded peer rejected
|
||
device-limit peer rejected
|
||
legacy migration переносит admin и peers корректно
|
||
```
|
||
|
||
Frontend checks:
|
||
|
||
```text
|
||
pnpm lint:eslint
|
||
pnpm build:prod
|
||
vue-tsc --noEmit
|
||
```
|
||
|
||
E2E/screenshot:
|
||
|
||
```text
|
||
RU sidebar expanded
|
||
RU sidebar collapsed
|
||
EN sidebar expanded
|
||
EN sidebar collapsed
|
||
Peer table 1366px
|
||
Peer table 1920px
|
||
Peer card/mobile layout
|
||
```
|
||
|
||
Acceptance criteria:
|
||
|
||
```text
|
||
Админ панели не отображается в списке peer’ов.
|
||
У админа нет Node URL, QR и connection password.
|
||
Peer не имеет password для входа в UI.
|
||
Все toast/confirm/validation сообщения переводятся.
|
||
Русская локаль не ломает sidebar.
|
||
Peer list не требует горизонтального скролла на 1366px.
|
||
Второстепенные peer-поля доступны через “Обзор”.
|
||
```
|
||
|
||
Главная мысль: не чинить это через `role` и CSS-отступы. Правильный продакшен-фикс — разделить admin identity и peer identity на уровне схемы, сервисов и UI. После этого локаль и таблица чинятся уже как нормальная фронтенд-задача, а не как борьба с последствиями смешанной модели.
|