Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf10810cfc | |||
| 6c286d1ff5 | |||
| a3445f79c5 | |||
| af9f476658 | |||
| ab788725cf | |||
| 65042ee335 | |||
| a8407cf16b | |||
| 82e5ca40cc | |||
| 7d486af712 | |||
| b9d3c03f8d | |||
| b315001288 | |||
| cb20d8d28f | |||
| 8dcb50a07c | |||
| 6d1686b2be | |||
| 162759c599 | |||
| c0a43ae915 |
@@ -8,6 +8,23 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Diagnostics больше не пишет root-артефакты в `/var/log/hy2xs`, которым владеет
|
||||
`hy2xs-admin`. Staging и архив перенесены в проверяемый через `lstat` каталог
|
||||
`/var/lib/hy2xs/diagnostics` (`root:root 0700`), имена создаются эксклюзивно,
|
||||
symlink и ослабленные права дают fail-closed отказ, а временный staging после
|
||||
упаковки удаляется. Чтение metadata теперь использует fallback только при
|
||||
`ENOENT`; ошибки прав, ввода-вывода и неверного типа больше не маскируются.
|
||||
|
||||
Очередной hardening-проход закрывает границу кодировок. Оркестратор теперь
|
||||
читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM
|
||||
как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile
|
||||
до разбора ключей. HTTP API до `encoding/json` отвергает повреждённый UTF-8 и
|
||||
непарные UTF-16 surrogate escape, поэтому вход или импорт больше не могут
|
||||
молча превратиться в другое значение через U+FFFD. Diagnostics сначала
|
||||
санитизирует данные в памяти и никогда не кладёт сырые env/YAML/journal во
|
||||
временный каталог. U+FEFF во всех слоях отнесён к документированному домену
|
||||
systemd, а управляющие `Cc` — к политике HY2XS.
|
||||
|
||||
Hardening-проход перед релизом `1.0.0`. Основная тема — сделать политику
|
||||
«только чистая установка» настоящим системным инвариантом, а не строчкой в
|
||||
документации.
|
||||
@@ -46,6 +63,932 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
результат на достаточно большом входе, опаснее отсутствующей: отсутствующая
|
||||
ничего не обещает.
|
||||
|
||||
Девятый проход — работа оператора в панели, по итогам приёмки `v1.0.0-rc1` на
|
||||
живом Debian 13. Общая тема прохода: обещания интерфейса, которые продукт не
|
||||
выполнял, хотя умел. Подпись под полем предлагала оставить секрет пустым, и
|
||||
сервер действительно умел его сгенерировать — до этой генерации не доходило
|
||||
управление. Контракт `currentColor` был объявлен в двух местах — и не
|
||||
действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла,
|
||||
войдите заново» существовала — и была недостижима сразу по двум причинам.
|
||||
|
||||
Десятый проход — разбор кода после девятого, на коммите `c0a43ae9`, со сверкой
|
||||
Hysteria-интеграции с официальной документацией Hysteria 2. Тема прохода:
|
||||
операции, у которых есть только одна из двух необходимых половин. Отключение
|
||||
пира записывало запрет и не разрывало его сессию. Ограничение устройств
|
||||
проверялось, пока отвечал Traffic Stats API, и молча снималось, когда он не
|
||||
отвечал. Журнал собирал структурные поля и записывал пустое сообщение. Разбор
|
||||
задокументирован в
|
||||
[docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md](docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md).
|
||||
|
||||
Одиннадцатый проход — второй разбор того же слоя, уже по состоянию после
|
||||
десятого. Тема: границы между частями access-control. Десятый проход починил
|
||||
одну операцию отзыва доступа и оставил остальные — удаление, импорт, смену
|
||||
секрета, урезание квоты и срока, снижение лимита устройств — в прежнем
|
||||
состоянии; правило доступа при этом продолжало существовать в двух
|
||||
экземплярах, написанных разными SQL-условиями, которые расходились ровно на
|
||||
границах. Проведены три границы: `состояние пира → решение о доступе`,
|
||||
`сохранённое изменение → живая сессия`, `планировщик → полностью
|
||||
принадлежащая ему работа`.
|
||||
|
||||
Двенадцатый проход — разбор состояния после одиннадцатого, снова со сверкой с
|
||||
официальной документацией Hysteria 2. Тема: **вторая попытка**. Одиннадцатый
|
||||
проход сделал правильным порядок «сначала запись, потом разрыв» и правильно
|
||||
запретил откат при неудаче разрыва — но не дал системе способа прийти к
|
||||
согласованному состоянию потом. Два состояния оставались навсегда: сессия
|
||||
пира, которого импорт переподписал или удаление убрало, и превышение лимита
|
||||
устройств после неудавшегося снижения. Вместе с ними закрыт второй TOCTOU в
|
||||
лимите устройств — переупорядочивание снимков `/online`, которое учёт
|
||||
разрешений сам по себе не ловил, а детектор гонок не мог показать в принципе.
|
||||
Разбор задокументирован в
|
||||
[docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md).
|
||||
|
||||
Тринадцатый проход — граница между HY2XS и Hysteria, со сверкой по исходникам
|
||||
тега `app/v2.12.2`, а не только по документации. Предыдущие два прохода привели
|
||||
в порядок внутреннюю логику отзыва доступа; здесь закрываются места, где эта
|
||||
логика соприкасается с чужим компонентом и с оператором: идентичность сессий,
|
||||
которая не менялась вместе с учётными данными и потому лишала цикл учёта
|
||||
единственного признака отзыва; адрес Traffic Stats API, у которого было два
|
||||
несовместимых контракта в одном продукте; формат журнала Hysteria, который
|
||||
разбирался неверно на каждой строке; и панель, показывавшая как факт то, чего
|
||||
никто не проверял. Разбор задокументирован в
|
||||
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
|
||||
|
||||
Четырнадцатый проход — по итогам runtime-прогона `v1.0.0-rc2` на чистом
|
||||
Debian 13. Тема: **разница между «поднялось» и «работает»**. Установка прошла
|
||||
целиком и объявила `INSTALL EXIT CODE: 0`, юнит был активен, порт слушал,
|
||||
`/healthz` отвечал `ok: true` — и вход в панель отдавал HTTP 500 на каждый
|
||||
запрос, потому что валидатор паниковал на теге правила, пережившего
|
||||
переименование. Ни один из трёх признаков готовности не отвечал на вопрос, ради
|
||||
которого они проверялись. Вместе с самим дефектом закрыт его класс: барьер,
|
||||
проверяющий каждый тег валидации на существование, и smoke, выполняющий
|
||||
настоящий вход. Прогон задокументирован в
|
||||
[docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md).
|
||||
|
||||
Пятнадцатый проход — разбор того, что четырнадцатый построил, со сверкой по
|
||||
исходникам используемых библиотек, а не по их описаниям. Тема прохода: **новый
|
||||
контракт, объявленный точнее, чем он есть на самом деле**. Четырнадцатый проход
|
||||
свёл правило о пароле в одно место и объявил его границей в 64 символа,
|
||||
обосновав выбор пределом bcrypt в 72 байта. Обоснование верно только для ASCII:
|
||||
`golang.org/x/crypto/bcrypt` считает БАЙТЫ и отвечает `ErrPasswordTooLong`, а не
|
||||
«молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция
|
||||
пакета до v0.28. Пароль из 64 кириллических букв (128 байт) проходил панель,
|
||||
оркестратор и DTO и падал в хешировании: системной ошибкой на штатной смене
|
||||
пароля, а после установки — отсутствием администратора вовсе. Хуже самого
|
||||
дефекта было то, что новый тест закреплял это значение как ожидаемое.
|
||||
|
||||
Вместе с ним закрыты три соседних расхождения того же контракта: `HashPassword`
|
||||
и bootstrap триммили пароль, у которого краевой пробел объявлен частью значения;
|
||||
панель считала длину в единицах UTF-16 (`async-validator`), тогда как сервер
|
||||
считает code points; и сам транспорт — `hy2xs.env` — не был форматом,
|
||||
переживающим произвольное значение, хотя его читает не только оркестратор, но и
|
||||
systemd.
|
||||
|
||||
Шестнадцатый проход — разбор пятнадцатого, со сверкой по исходникам systemd
|
||||
v257.13, то есть той самой линии, что стоит на Debian 13. Тема та же и на слой
|
||||
глубже: **контракт, объявленный шире, чем его принимает чужая сторона**.
|
||||
Пятнадцатый проход сделал транспорт lossless для значений, которые systemd
|
||||
принимает, но не спросил, какие значения он принимает вообще. Вместе с этим
|
||||
закрыты рецепт восстановления доступа, ставший опасным ровно оттого, что файл
|
||||
научился нести произвольные значения, и порядок, при котором детерминированная
|
||||
ошибка конфигурации обнаруживалась уже после мутаций хоста.
|
||||
|
||||
### Исправлено — вход в панель
|
||||
|
||||
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На
|
||||
`LoginDto.Username` стоял тег `validateStr` — правило с таким именем не
|
||||
регистрировалось: при переименовании в `credentialStr` правка не доехала до
|
||||
одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее
|
||||
имя. `go-playground/validator` на неизвестный тег ПАНИКУЕТ при разборе
|
||||
структуры, `gin.Recovery` превращал панику в HTTP 500, и панель после чистой
|
||||
установки была недоступна целиком.
|
||||
|
||||
- **Ни один тест не прогонял DTO через production-валидатор.** Проверялся сам
|
||||
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
|
||||
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
|
||||
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
|
||||
тега не доходит. Теперь `TestEveryValidationTagIsRegistered` извлекает все
|
||||
теги `validate:"…"` из `apps/model/**` и предъявляет каждый валидатору
|
||||
отдельно — закрыт класс, а не найденный экземпляр.
|
||||
|
||||
- **Установка не проверяла, что в панель можно войти.** Smoke отвечал на этот
|
||||
вопрос фактом `LISTEN` и ответом `/healthz`; RC2 доказал, что оба бывают
|
||||
истинны при полностью недоступной панели. Теперь smoke выполняет настоящий
|
||||
`POST /api/auth/login` bootstrap-учётными данными и требует конверт успеха с
|
||||
непустым токеном — по коду HTTP это неотличимо, админка отвечает `200 OK` и
|
||||
на отказ. Отрицательная проба выполняется в любом режиме операции.
|
||||
|
||||
- **Оркестратор не знал контракта, который сам порождает.** `HY2XS_ADMIN_USER`
|
||||
по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и
|
||||
установка с ним завершалась успешно, создавая учётную запись, под которой
|
||||
невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба
|
||||
значения теперь проверяются при разборе окружения, умолчание сведено к
|
||||
`hy2xsadmin` во всех трёх местах.
|
||||
|
||||
- **Панель была строже сервера и запирала оператора после смены пароля.** Форма
|
||||
входа ограничивала пароль 32 символами при серверном пределе в 64, а форма
|
||||
смены пароля назначала до 64: пароль, назначенный штатной операцией, после
|
||||
этого не вводился. Набор символов на пароле отвергал значение, которое сервер
|
||||
принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь
|
||||
объявлен один раз в `apps/credential/admin.go`, а копии в панели и
|
||||
оркестраторе сверяются с ним тестами, читающими Go-исходник.
|
||||
|
||||
- **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный
|
||||
дефис в `[a-zA-Z0-9!@#$%^&*()_+-=]` превращал `+-=` в диапазон, впускающий
|
||||
`, - . / 0-9 : ; < =`. С серверным набором это совпадало по совпадению: оба
|
||||
несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на
|
||||
установленных серверах.
|
||||
|
||||
- **Красная рамка ошибки обводила не то, что видит оператор.** Element Plus
|
||||
рисует состояние отказа на `el-input__wrapper` селектором из четырёх классов,
|
||||
а форма входа рисует видимую рамку поля на `el-form-item` и гасила чужую тень
|
||||
селектором из трёх — и проигрывала по специфичности. Рамка ложилась вокруг
|
||||
одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед
|
||||
переключателем видимости. Индикация перенесена на элемент, который оператор и
|
||||
видит полем.
|
||||
|
||||
- **Требование не называлось.** «Неверный формат логина» и «Некорректное
|
||||
значение» не давали оператору ни одного способа узнать, что от него хотят:
|
||||
набор символов логина приходит из `hy2xs.env` и в панели нигде не показан.
|
||||
Фразы форм и серверная причина `credential_format` теперь перечисляют границы
|
||||
и набор.
|
||||
|
||||
- **`last_login_at` не записывался никогда.** Колонка объявлена в схеме и в
|
||||
entity, `UpdateAdminLastLoginAt` существовал и не вызывался ниоткуда. Отметка
|
||||
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
|
||||
вход не отменяет, но попадает в журнал.
|
||||
|
||||
### Исправлено — домен значений файла окружения
|
||||
|
||||
- **Контракт не знал, какие значения systemd вообще принимает.** Перед тем как
|
||||
принять пару, systemd прогоняет ключ и значение через `utf8_is_valid`
|
||||
(`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает
|
||||
`-EINVAL` — то есть НЕзагруженный `EnvironmentFile=` и юнит, который не
|
||||
стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`)
|
||||
отвергает суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`.
|
||||
Пароль `abcde` + `U+FDD0` — шесть символов, восемь байт, ни одного
|
||||
управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в
|
||||
`hy2xs.env`, и после этого админка не поднималась: тот же класс дефекта, ради
|
||||
уничтожения которого контракт и существует, только слоем ниже. Введён
|
||||
`IsEnvTransportableText` (Go) / `isEnvTransportable` (TS), повторяющий
|
||||
множество systemd точно — не шире и не уже.
|
||||
|
||||
- **Одиночный суррогат подменял бы секрет молча.** Строка JavaScript вправе
|
||||
содержать непарный суррогат, а `TextEncoder` заменяет его на `U+FFFD`. Без
|
||||
отдельной проверки в файл уехал бы ДРУГОЙ пароль, а не отказ. Панель и
|
||||
оркестратор отвергают такие значения явно.
|
||||
|
||||
- **Проверка транспорта приписывала формату чужие запреты.** Она отвергала C0 и
|
||||
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
|
||||
двойных кавычек перевод строки накапливается как обычный байт и переживает
|
||||
round-trip. Именно эта подмена и позволила проверке не знать про
|
||||
noncharacters. Домен транспорта и политика продукта теперь разделены и
|
||||
проверяются отдельными тестами.
|
||||
|
||||
- **Политика была шире кода ровно на C1.** Документация обещала «без
|
||||
управляющих символов», а проверялись только `U+0000..U+001F` и `U+007F`.
|
||||
Запрещена категория `Cc` целиком, плюс `U+FEFF` — последний отдельным
|
||||
решением продукта, а не форматом: `0xFEFF & 0xFFFE` это `0xFEFE`, и systemd
|
||||
такое значение принимает (комментарий `/* BOM */` в его исходнике относится к
|
||||
`U+xFFFE` и является неточностью самого systemd).
|
||||
|
||||
- **Порт автомата расходился с upstream на `\<CR>`.** В состоянии
|
||||
`DOUBLE_QUOTE_VALUE_ESCAPE` systemd пишет `c != '\n'`, а не проверку на любой
|
||||
перевод строки (в `VALUE_ESCAPE` — наоборот, `strchr(NEWLINE, c)`). Порт
|
||||
съедал и `\<LF>`, и `\<CR>`, то есть прочитал бы такое значение иначе, чем
|
||||
тот, для кого файл в конечном счёте написан.
|
||||
|
||||
- **Комментарий обещал одно намеренное расхождение с systemd, а их два.** Кроме
|
||||
строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла, тогда
|
||||
как systemd принимает накопленное. Оба расхождения fail-closed и теперь
|
||||
названы оба.
|
||||
|
||||
### Исправлено — рецепт восстановления выполнял env-файл как код
|
||||
|
||||
- **`set -a; . /etc/hy2xs/hy2xs.env` в инструкции «Забыт пароль
|
||||
администратора».** Строка стала опасной ровно тогда, когда файл научился нести
|
||||
произвольные значения. Для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(…)"` —
|
||||
буквальное значение: подстановок в `EnvironmentFile=` нет вовсе. Но `.`
|
||||
обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку
|
||||
команд — от root, прямо в рецепте восстановления доступа. Причём соседний
|
||||
раздел той же страницы уже правильно запрещал `source`/`eval` для
|
||||
`bootstrap-admin.secret`: документ запрещал действие и тут же его предлагал.
|
||||
Рецепт читает нужные значения как ДАННЫЕ; поставлен гейт приёмки, запрещающий
|
||||
возврат `source`/`.`/`eval` над этими файлами в командах документации и в
|
||||
скриптах.
|
||||
|
||||
### Исправлено — отказ приходил после мутаций хоста
|
||||
|
||||
- **Проверка транспорта жила только внутри `renderRuntimeEnv`.** Она срабатывала
|
||||
на шаге «write runtime env» — уже после bootstrap оркестратора, установки
|
||||
пакетов и раскладки файловой системы, — а read-only `preflight-install`
|
||||
говорил PASS: он зовёт `parseRuntimeEnv` и ничего не рендерит.
|
||||
Детерминированно известная ошибка конфигурации роняла операцию, оставив за
|
||||
собой изменённый хост, что прямо противоречит контракту PHASE 0.
|
||||
`validateRuntimeEnvTransport` вызывается теперь из `parseRuntimeEnv` и
|
||||
проходит по ВСЕМ парам `runtimeEnvEntries`: ограничение принадлежит формату, а
|
||||
не полю пароля, и `HY2XS_ADMIN_CON_PASS` сломал бы загрузку юнита так же.
|
||||
|
||||
### Исправлено — контракт пароля администратора
|
||||
|
||||
- **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64
|
||||
СИМВОЛА и обоснован пределом bcrypt в 72 БАЙТА — верно только для ASCII. У 64
|
||||
символов длина от 64 до 256 байт: `64 x "я"` — это 128 байт, `19 x "😀"` — 76.
|
||||
`bcrypt.GenerateFromPassword` (`golang.org/x/crypto@v0.55.0`, `bcrypt.go:96`)
|
||||
отвечает на такое `ErrPasswordTooLong`. Отказ приходил не с формы, а из
|
||||
хеширования: на смене пароля — системной ошибкой, при установке — падением
|
||||
старта админки, то есть сервером без администратора после
|
||||
`INSTALL EXIT CODE: 0`. Введена отдельная константа `AdminPasswordMaxBytes =
|
||||
72`, зеркальная в оркестраторе и панели; граничные значения (36 x `я`, 18 x
|
||||
`😀` = 72 байта) закреплены тестами во всех четырёх слоях.
|
||||
|
||||
- **Тест закреплял дефект как ожидаемое поведение.** Проверка «длина считается в
|
||||
символах, а не в байтах» требовала ПРИЁМА пароля из 64 кириллических букв —
|
||||
ровно того значения, на котором продукт ломался. Тест инвертирован.
|
||||
|
||||
- **Пароль триммился в двух местах вопреки собственному контракту.** Контракт
|
||||
объявляет краевой пробел частью пароля и не снимает его ни на форме входа, ни
|
||||
при смене. При этом `util.HashPassword` вёл собственную проверку
|
||||
`len(strings.TrimSpace(password)) < 6`, а bootstrap читал
|
||||
`strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Следствия:
|
||||
пароль `abcde ` принимали все двери продукта и не мог захешировать никто, а
|
||||
первая учётная запись создавалась не с тем паролем, который оператор записал в
|
||||
`hy2xs.env`. Оба тримминга убраны.
|
||||
|
||||
- **Контракт переехал в leaf-пакет.** Собственная проверка в `HashPassword`
|
||||
существовала не по недосмотру: `service` импортирует `util`, и позвать оттуда
|
||||
контракт было невозможно. Правило переехало в `apps/credential`, ниже `util`,
|
||||
`dao` и `service`, — теперь у него физически не может появиться второго
|
||||
экземпляра.
|
||||
|
||||
- **Границы пароля стояли рядом с правилом, а не внутри него, и были неполны.**
|
||||
`min=6,max=64` в тегах DTO — та же конструкция, от которой уже отказались на
|
||||
логине; у пароля она вдобавок не могла выразить границу в байтах, потому что
|
||||
правила длины в байтах у `go-playground/validator` нет. Введено одно правило
|
||||
`adminPassword` и код причины `admin_password_format`, называющий обе границы.
|
||||
|
||||
- **Панель считала длину в единицах UTF-16.** Element Plus делегирует правила
|
||||
формы библиотеке `async-validator`, а она сравнивает `min`/`max` строки с
|
||||
`String.prototype.length`. Пароль из трёх эмодзи имел `length === 6`, проходил
|
||||
минимум формы и получал отказ сервера, который панель не могла объяснить:
|
||||
сервер видит три руны. Встроенные `min`/`max` на пароле заменены общим
|
||||
правилом `adminPasswordFormRule`, считающим code points и UTF-8 байты.
|
||||
|
||||
- **`hy2xs.env` не был форматом.** Значения писались интерполяцией, а читались
|
||||
построчным `split("=")` с `trim()`; совпадение поведения держалось на том, что
|
||||
в них не встречалось ни пробелов по краям, ни кавычек, ни обратных слешей. При
|
||||
этом файл читает не только оркестратор: он объявлен `EnvironmentFile=` в юните
|
||||
`hy2xs-admin`, и у незакавыченного значения systemd срезает краевые пробелы и
|
||||
трактует `\` как escape. Пароль с краевым пробелом терялся ещё до запуска
|
||||
админки. Добавлен `orchestrator/src/lib/envFile.ts` — порт конечного автомата
|
||||
`parse_env_file_internal` из systemd и обратный ему кодировщик; тем же
|
||||
кодировщиком пишется `bootstrap-admin.secret`. Обычные значения остаются без
|
||||
кавычек, поэтому релизные гейты и инструкции оператора не меняются.
|
||||
|
||||
- **Управляющие символы запрещены контрактом.** Формат `KEY=VALUE` их не несёт —
|
||||
перевод строки является границей записи, — а ввести такой пароль в
|
||||
однострочное поле формы входа всё равно нельзя: он умел только запереть
|
||||
оператора. Запрет делает транспорт полным: всякий принятый контрактом пароль
|
||||
доезжает до bcrypt побайтово тем же, каким его задал оператор.
|
||||
|
||||
- **Отрицательная проба smoke не доказывала отказа.** Она сверяла `%{http_code}`
|
||||
с `200` и доказывала ровно одно: что запрос не закончился пятисоткой — админка
|
||||
отвечает `200 OK` и на успешный вход тоже. Пароль пробы вдобавок был записан
|
||||
литералом и теоретически мог оказаться настоящим. Теперь пароль генерируется в
|
||||
момент пробы, а результат сверяется с конвертом: `code: 50000`, причина
|
||||
`invalid_credentials`, отсутствие `accessToken`.
|
||||
|
||||
- **Положительная проба smoke читала пароль шелл-конвейером.** `grep … | cut -d=
|
||||
-f2-` с `.trim()` был третьим по счёту слоем, срезающим краевые пробелы:
|
||||
проба взяла бы не тот пароль и объявила бы рабочую установку сломанной.
|
||||
Bootstrap-секрет читается парсером собственного формата, минуя shell.
|
||||
|
||||
### Исправлено — правило доступа
|
||||
|
||||
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
||||
существовало в двух экземплярах: SQL-условием внутри `Hysteria2Auth` и
|
||||
другим SQL-условием внутри cron. Второе не было отрицанием первого, и
|
||||
расхождение приходилось на границы — `quota = 0`, `usage = quota`,
|
||||
`now = expiresAt`, `now = bannedUntil`: авторизация отказывала, cron сессию
|
||||
не рвал. Условие cron требовало СТРОГОГО превышения квоты, а счётчики растут
|
||||
порциями по ответу Traffic Stats API, поэтому точное равенство — обычный
|
||||
исход очередного сбора. Пир с исчерпанной квотой не пускался заново, но его
|
||||
живая сессия не разрывалась никогда.
|
||||
|
||||
Политика вынесена в одну функцию `peerAccessDenied`; авторизация ищет пира
|
||||
только по `secret_digest`, cron применяет ту же функцию. `quota = -1` —
|
||||
единственный способ снять ограничение, `quota = 0` означает ноль байтов,
|
||||
`usage = quota` означает исчерпанный лимит, `bannedUntil = now` означает
|
||||
закончившуюся блокировку. Строка без решающего поля трактуется как
|
||||
повреждённая и ведёт к отказу.
|
||||
|
||||
### Исправлено — операции, оставляющие живую сессию
|
||||
|
||||
- **Удаление пира не отзывало доступ и теряло `authId`.** `DeletePeer` состоял
|
||||
из одного `dao.DeletePeer`: строка исчезала, живая QUIC-сессия оставалась, а
|
||||
вместе со строкой исчезал `auth_id` — единственное, чем эту сессию можно было
|
||||
бы завершить. Состояние становилось невосстановимым. Теперь: прочитать пира и
|
||||
запомнить `authId` → записать `disabled=1` → `/kick` → удалить строку. При
|
||||
неудаче разрыва строка остаётся отключённой, и оператор повторяет удаление.
|
||||
|
||||
- **Разрыв выполнялся только при `disabled=1`.** Мимо проходили смена секрета,
|
||||
урезание квоты ниже израсходованного, перенос срока в прошлое и снижение
|
||||
лимита устройств — каждая из них закрывает доступ, но сессию не трогала.
|
||||
Правило асимметрично: ограничение применяется немедленно, послабление — нет.
|
||||
При любом сочетании изменений уходит ровно один `/kick`.
|
||||
|
||||
- **Импорт не завершал сессии переписанных пиров.** Импорт переписывает
|
||||
`auth_id`, секрет, квоту, срок и `disabled` целиком. Старые `authId`
|
||||
собираются внутри транзакции — после commit их в базе уже нет, — а разрыв
|
||||
идёт после commit: до него клиент успел бы переподключиться к ещё не
|
||||
изменённому пиру.
|
||||
|
||||
- **Единственный вход к `/kick`.** Все операции идут через один
|
||||
`reconcileLiveSessions`, а он — через `disconnectAuthIDs`, который принимает
|
||||
готовые идентификаторы, дедуплицирует их, разбивает на части и не обращается
|
||||
к базе вовсе. Пока обращений к `/kick` было два, они расходились: у cron не
|
||||
было ни дедупликации, ни разбиения, зато был POST с пустым массивом каждые 30
|
||||
секунд.
|
||||
|
||||
- **Формулировка частичного результата больше не называет операцию.** Через
|
||||
`peer_disconnect_failed` отчитываются восемь операций; прежнее «новые
|
||||
подключения пира запрещены» было верно ровно для отключения пира, а для
|
||||
удалённого — бессмысленно. Контроллеры удаления и импорта переведены на
|
||||
`failService`, панель разбирает исход импорта и обновляет список при любом
|
||||
результате.
|
||||
|
||||
### Исправлено — цикл учёта
|
||||
|
||||
- **Джоба убегала из жизненного цикла планировщика.** `CronHandleAccount`
|
||||
запускала горутину, которая запускала ещё две. Для планировщика джоба
|
||||
заканчивалась почти мгновенно, поэтому `StopCron()` не ждал настоящей работы:
|
||||
`releaseResource()` закрывал SQLite, а горутины продолжали в неё писать.
|
||||
Параллельность обеих половин означала ещё и то, что принудительное отключение
|
||||
читало счётчики до записи снятой дельты. Теперь джоба синхронна, под одним
|
||||
мьютексом на весь цикл, и порядок строгий: сбор трафика, затем enforcement.
|
||||
|
||||
- **Три nil-разыменования роняли процесс целиком.** `*trafficSecretConfig.Value`,
|
||||
`*item.AuthId` в принудительном отключении и `*item.Id` в сбросе трафика — все
|
||||
внутри горутин, где их некому перехватить, то есть каждое означало падение
|
||||
сервиса вместе с обработчиком machine-auth.
|
||||
|
||||
- **Гейт `Hysteria2IsRunning` удалён из cron.** `util.Exec` не отличает «служба
|
||||
неактивна» от «спросить не удалось», поэтому сломанный `systemctl` при живой
|
||||
Hysteria молча отключал и учёт трафика, и принудительное отключение — без
|
||||
единой строки в журнале.
|
||||
|
||||
- **Потеря дельты трафика больше не молчит.** `GET /traffic?clear=1`
|
||||
деструктивен: счётчики Hysteria обнуляются сразу после отправки ответа.
|
||||
Прежний код на отказе записи делал `continue`, и дельта исчезала, не оставив
|
||||
следа в исходе джобы. Полное решение требует смены модели учёта
|
||||
(недеструктивное чтение плюс долговременные checkpoint'ы) и в `1.0.0`
|
||||
намеренно не вводится: квота — операционный предел доступа, а не учёт с
|
||||
финансово значимым каждым байтом.
|
||||
|
||||
### Исправлено — лимит устройств под нагрузкой
|
||||
|
||||
- **Параллельные подключения превышали `maxDevices`.** Между чтением `/online`
|
||||
и ответом «allow» место ничем не удерживалось: при `online = max-1` два
|
||||
одновременных запроса получали разрешение оба. Мьютекс вокруг `/online` этого
|
||||
не чинит — ответив «allow», админка не создаёт подключение, и следующий
|
||||
запрос продолжает видеть прежнее число. Появился process-local учёт выданных,
|
||||
но ещё не проявившихся разрешений: решение принимается по сумме «подключено
|
||||
плюс зарезервировано», рост `online` снимает соответствующее число
|
||||
резерваций, протухшие снимаются по внутреннему TTL. Ни Redis, ни таблиц в
|
||||
базе, ни распределённых блокировок: HY2XS — один процесс на одном сервере.
|
||||
|
||||
### Исправлено — сходимость отзыва доступа (двенадцатый проход)
|
||||
|
||||
- **Устаревший снимок `/online` возвращал уже занятое место.** Учёт выданных
|
||||
разрешений закрыл сравнение двух одинаковых снимков, но сетевой запрос
|
||||
по-прежнему выполнялся вне блокировки, поэтому снимки приходили в резервацию
|
||||
в произвольном порядке. Более старый обгонял более новый и откатывал
|
||||
`lastOnline` назад: `A` получил разрешение при `online = 0`; `C` обработал
|
||||
`online = 1` первым и признал разрешение `A` проявившимся; пришедший следом
|
||||
`B` со своим устаревшим `0` увидел место снова свободным. При
|
||||
`maxDevices = 1` подключений становилось два. Детектор гонок здесь молчит
|
||||
принципиально — вся работа с памятью защищена мьютексом, гонка логическая.
|
||||
|
||||
Последовательность «прочитать `/online` → занять место» выполняется под
|
||||
замком **по `authId`**, а не одним на процесс: внутри неё идёт сетевой
|
||||
запрос, и общий замок выстроил бы подключения всех пиров в очередь за одним
|
||||
HTTP-обменом. Карта замков не растёт — запись живёт ровно столько, сколько
|
||||
есть желающие её взять.
|
||||
|
||||
- **Живая сессия без строки в базе не завершалась никогда.** Цикл учёта читал
|
||||
`dao.ListPeer("auth_id in ?")` и обходил найденные строки, поэтому `authId`,
|
||||
которому в базе ничего не соответствует, молча выпадал. А именно он и
|
||||
остаётся единственным следом сессии после неудавшегося второго шага: импорт
|
||||
заменил `auth_id`, удаление убрало строку. Повторить операцию в этом
|
||||
состоянии невозможно — повтор того же импорта читает из базы уже новое
|
||||
значение и рвёт его, — а восстановить состояние переподключением нельзя:
|
||||
авторизация нового значения не знает. Сессия жила неограниченно долго.
|
||||
|
||||
- **Снижение `maxDevices` после неудавшегося разрыва не имело второй попытки.**
|
||||
Условие сравнивало `*peerDto.MaxDevices < *before.MaxDevices`, а форма при
|
||||
правке отправляет все поля, поэтому повторное сохранение давало `1 < 1` и
|
||||
разрыва не делало. Лимит устройств в политику доступа не входит и входить не
|
||||
должен — это свойство сессий, а не пира, — поэтому механизма схождения у него
|
||||
не было вовсе, в отличие от `disabled`, квоты, срока и блокировки.
|
||||
|
||||
- **Цикл учёта стал сверкой живых сессий.** Обход идёт по каждому `authId` из
|
||||
`/online`: нет строки в базе → разрыв; `peerAccessDenied` → разрыв;
|
||||
непригодный `maxDevices` → разрыв; устройств больше разрешённого → разрыв.
|
||||
Отказ базы при этом не рвёт ничего: «пира нет» и «прочитать не удалось» —
|
||||
разные ответы, и трактовка второго как первого отключила бы всех
|
||||
подключённых пиров сразу при недоступной SQLite. Таблицы отложенных
|
||||
операций, очереди retry и хранимого «списка того, что не удалось разорвать»
|
||||
не появилось: список живых сессий уже есть, и это `/online`. Число устройств
|
||||
сверено с официальным контрактом Traffic Stats API — `/online` возвращает
|
||||
количество экземпляров клиента Hysteria, а не число proxy-потоков.
|
||||
|
||||
- **Go 1.26.7 → 1.26.8.** Patch-релиз от 2026-09-01 (fixes в cgo, компиляторе,
|
||||
runtime, `debug/elf` и `os`). Stdlib целиком попадает в production-бинарь,
|
||||
поэтому «на один патч позади» — свойство выпускаемого артефакта, а не среды
|
||||
сборки. Обновлены `GO_VERSION` с контрольной суммой и `toolchain` в
|
||||
`apps/go.mod`: расхождение между ними роняет сборку на
|
||||
`verify_go_toolchain_contract`. Major не менялся — линия 1.26 поддерживается.
|
||||
|
||||
- **Учёт разрешений больше не растёт бесконечно.** Запись снималась только на
|
||||
ветке отказа: после успешной выдачи она оставалась с непустым списком, а
|
||||
когда разрешение протухало, снять её было уже некому — следующего обращения
|
||||
к этому `authId` могло не быть никогда. В карте копились удалённые пиры и
|
||||
старые идентификаторы, переписанные импортом. Уборка идёт по фактической
|
||||
картине подключений в том же цикле учёта — единственном месте продукта, где
|
||||
она известна целиком.
|
||||
|
||||
### Исправлено — отзыв учётных данных и граница с Hysteria (тринадцатый проход)
|
||||
|
||||
- **Смена секрета не отзывала доступ гарантированно.** Отзыв состоит из двух
|
||||
шагов, и второй умеет не удаться — сходимость обязан обеспечить цикл учёта. Но
|
||||
сверять ему было нечем: `auth_id` при смене секрета оставался прежним, поэтому
|
||||
сессия, установленная по отозванным учётным данным, называлась тем же
|
||||
значением, что и законная, пир в базе существовал, доступ был открыт,
|
||||
устройств не больше разрешённого. Признака «установлена по недействительному
|
||||
секрету» в системе не существовало.
|
||||
|
||||
Хуже того, у этого состояния есть путь **без единой неудачи**. Hysteria
|
||||
дожидается ответа backend-auth и только после `ok = true` помечает соединение
|
||||
аутентифицированным и сообщает о нём Traffic Stats API (проверено по
|
||||
исходникам `app/v2.12.2`), поэтому `/kick`, прошедший успешно, пока
|
||||
авторизация по старому секрету ещё выполнялась, этого соединения не видит.
|
||||
Атомарной пары «решение авторизации + регистрация онлайна» upstream API не
|
||||
даёт, и повторным чтением базы перед ответом окно не закрыть.
|
||||
|
||||
Теперь новое поколение учётных данных получает новый `auth_id`, а `/kick` идёт
|
||||
по старому: пережившая сессия становится orphan и завершается очередным циклом
|
||||
учёта — механизмом, который уже существует. Правило действует на обеих дверях
|
||||
к смене секрета, включая импорт, где случай «прежний `auth_id` + новый секрет»
|
||||
проходил мимо. Ротация происходит тогда и только тогда, когда меняется
|
||||
`secret_digest`. Цена названа прямо: трафик доживающей сессии за эти секунды
|
||||
не приписывается пиру и попадает в потери цикла.
|
||||
|
||||
- **Адрес Traffic Stats API имел два контракта.** Оркестратор принимал любой
|
||||
IPv4 и честно подставлял его в `trafficStats.listen`, а проверка профиля
|
||||
сверяла конфиг с тем же значением — все гейты проходили. Админка при этом
|
||||
берёт из `listen` только порт и всегда идёт на `127.0.0.1`. Валидная по всем
|
||||
проверкам конфигурация разводила компоненты по разным адресам и выключала
|
||||
лимит устройств, учёт трафика и принудительное отключение разом: `/online`
|
||||
недоступен → авторизация fail-closed → не подключается никто. Адрес
|
||||
зафиксирован на `127.0.0.1`, а админка называет расхождение вместо молчаливой
|
||||
подстановки loopback.
|
||||
|
||||
- **JSON-журнал Hysteria не разбирался ни одной строкой.** Юнит запускает
|
||||
Hysteria с `HYSTERIA_LOG_FORMAT=json`, но `time` в этом формате — число, и
|
||||
притом дробное (`EpochMillisTimeEncoder` делит наносекунды на миллисекунду),
|
||||
поэтому разбор в структуру со строковым полем падал всегда и уходил в
|
||||
fallback: панель показывала сырой JSON. Замена типа поля на `int64` не
|
||||
помогла бы. Разбор ведётся по фактическому формату, а структурный контекст
|
||||
записи (`addr`, `id`, `error`, `listen`, …) больше не выбрасывается, а
|
||||
дописывается к сообщению в устойчивом порядке и проходит санитайз. Заодно
|
||||
перестали теряться записи, у которых journald отдаёт `MESSAGE` массивом байт.
|
||||
|
||||
- **«Служба остановлена» и «состояние неизвестно» были одним значением.**
|
||||
`util.Exec` выбрасывает вывод команды при ненулевом коде возврата, а
|
||||
`systemctl is-active` отвечает словом состояния в stdout вместе с кодом 3 —
|
||||
прочитать его было нечем. Дашборд из-за этого умел утверждать «Hysteria
|
||||
остановлена» и «Traffic Stats API доступен» одновременно: доступность API
|
||||
выводилась из того же ответа systemd, а не из обращения к API. Появился
|
||||
`util.ExecProbe`, состояние службы стало трёхзначным, а доступность API —
|
||||
независимым фактом. Список пиров при недоступном API отвечает «онлайн
|
||||
неизвестен», а не «все офлайн».
|
||||
|
||||
- **Страница конфигурации показывала дефолты UI вместо файла.** Ответ сервера
|
||||
накладывался на полный объект значений по умолчанию, поэтому отсутствующая
|
||||
секция `trafficStats` показывалась как `:9999`, явное `speedTest: false`
|
||||
считалось ненастроенным, а `ignoreClientBandwidth` без блока `bandwidth` не
|
||||
показывался вовсе. Экран, существующий ради диагностики расхождений, эти
|
||||
расхождения скрывал. Теперь панель показывает записанные значения (отличая
|
||||
«не задано» от значения) и отдельно перечисляет секции вне production-профиля.
|
||||
Три редактора, которые ничего не сохраняли, удалены вместе с их компонентами.
|
||||
|
||||
- **Читающий экран отдавал больше секретов, чем выгрузка.** Пароль обфускации,
|
||||
токены ACME DNS, учётные данные outbound-прокси и masquerade уезжали в
|
||||
браузер в открытом виде, хотя санитизированный экспорт того же конфига их
|
||||
вырезает. Теперь вместо значения показывается диагностический факт: «задан» /
|
||||
«не задан», имена параметров без значений, auth-URL с вырезанным токеном.
|
||||
|
||||
- **Секрет за YAML-якорем покидал сервер.** Санитайзер выгрузки не обрабатывал
|
||||
`yaml.AliasNode`: значение по ссылке оставалось нетронутым, а объявление
|
||||
якоря стояло под несекретным именем ключа — секрет уезжал в файл дважды.
|
||||
Обход идёт по цели ссылки, с защитой от циклов: `yaml.v3` на ссылке,
|
||||
указывающей на предка, строит действительно циклический граф узлов.
|
||||
|
||||
- **Hysteria больше не проверяет обновления сама.** В сборочном и e2e окружении
|
||||
`HYSTERIA_DISABLE_UPDATE_CHECK=1` стоял, а в production-юните — нет. Версией
|
||||
владеет один источник (`versions.env` → сборка → пакет → оркестратор), и
|
||||
production не имеет права отличаться от тестового окружения.
|
||||
|
||||
- **Удалены мёртвые остатки прежней архитектуры.** `util.CompareVersion`
|
||||
(лексикографическое сравнение версий без потребителя: `2.10 < 2.9`),
|
||||
`service.ReleaseHysteria2` (пустая заглушка, вызывавшаяся при завершении
|
||||
сервиса), `PeerClientConfigVo.QrCode` (второй канал доставки QR, который
|
||||
панель рисует сама), компонент `UnitSelect` и три функции `utils/byte.ts`.
|
||||
|
||||
- **Гейт освобождения admission-замка проверял форму, а не замок.**
|
||||
`/defer\s+\w+\(\)/` означало «в функции есть какой-нибудь отложенный вызов» и
|
||||
пережило бы `defer someOtherCleanup()` рядом со взятым замком. Теперь имя
|
||||
переменной берётся из самого присваивания.
|
||||
|
||||
- **Панель считала wildcard нормальным адресом control plane.** Признак на
|
||||
странице конфигурации отвечал только на вопрос «достучится ли админка»,
|
||||
поэтому `0.0.0.0` показывался без замечаний — хотя внутренний Traffic Stats
|
||||
API при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации
|
||||
не создаёт. Теперь показываются три состояния: канон профиля, wildcard
|
||||
(предупреждение) и недостижимый адрес (ошибка); backend при этом по-прежнему
|
||||
отвечает только на вопрос достижимости — превращать лишнюю публикацию в отказ
|
||||
обслуживания значило бы отключить всех пиров. Заодно исправлено ложное
|
||||
утверждение в комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это
|
||||
все интерфейсы, а не loopback.
|
||||
|
||||
- **«Какой-нибудь loopback» не означает «достижим по 127.0.0.1».** Проверка
|
||||
принимала любой `ip.IsLoopback()`, то есть считала рабочим и `127.0.0.5`.
|
||||
Слушатель на конкретном адресе принимает соединения только на него
|
||||
(`bind 127.0.0.5` + `dial 127.0.0.1` = connection refused, проверено), а слой
|
||||
proxy обращается строго к `http://127.0.0.1:<port>`: такой адрес выглядел
|
||||
локальным, ломал контур доступа целиком, и админка об этом молчала.
|
||||
Принимаются ровно `127.0.0.1`, `0.0.0.0` и пустой хост; свойство закреплено
|
||||
тестом с настоящими сокетами, а гейт приёмки запрещает возврат `IsLoopback()`
|
||||
и требует негативного случая в тестах.
|
||||
|
||||
### Исправлено — устаревшие утверждения приёмки
|
||||
|
||||
- **Гейт «frontend ACME registry matches current upstream» описывал снятую
|
||||
архитектуру.** Он требовал, чтобы страница Hysteria содержала жёсткий список
|
||||
ACME DNS-провайдеров (`cloudflare` … `vultr`) и не содержала удалённого
|
||||
upstream `namedotcom`. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор
|
||||
провайдера: список в UI был вторым экземпляром upstream-реестра и мог от него
|
||||
отстать. После перевода страницы в read-only диагностику реестра нет и быть не
|
||||
должно — имя провайдера читается из фактического конфига и показывается как
|
||||
есть, поэтому новый upstream-провайдер отображается без правок панели.
|
||||
Возврат списка ради прохождения grep'а создал бы фиктивный реестр,
|
||||
существующий только для гейта, поэтому исправлен гейт: он проверяет
|
||||
действующий контракт (провайдер приходит строкой и рисуется как значение,
|
||||
параметры DNS — только именами, селектора на странице нет). То же утверждение
|
||||
поправлено в матрице приёмки, а контрактный тест панели теперь требует
|
||||
ОТСУТСТВИЯ всех восьми имён провайдеров в исходнике страницы.
|
||||
|
||||
### Исправлено — зависимости frontend
|
||||
|
||||
- **`browserslist@4.21.11` содержал high-advisory** (уязвимы `<= 4.28.6`) и
|
||||
приходил транзитивно через `autoprefixer` и `update-browserslist-db`. Гейт
|
||||
сборки отработал ровно так, как задуман: `pnpm audit` по всему lock-графу
|
||||
остановил релиз до создания tarball. Закрыто точечным `pnpm.overrides` на
|
||||
`4.28.7` — точной версией, а не диапазоном: security-патч обязан быть
|
||||
детерминированным и не тащить за собой чужой major. Обновилось только
|
||||
поддерево `browserslist` (`caniuse-lite`, `electron-to-chromium`, `escalade`,
|
||||
`node-releases`, `update-browserslist-db`); `autoprefixer`, Vite и остальной
|
||||
граф не тронуты.
|
||||
|
||||
### Исправлено — гейты сборки
|
||||
|
||||
- **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не
|
||||
возвращает успех из ветки ошибки» была записана регуляркой
|
||||
`err != nil \{[\s\S]*?return \*peer\.Id`, а ленивый `[\s\S]*?` свободно
|
||||
пересекает границы блоков: она давала совпадение на любой функции, где после
|
||||
какой-нибудь проверки ошибки ниже стоит успешный возврат. Проверено на коде
|
||||
из `HEAD` — гейт нельзя было удовлетворить, не сломав продукт. Тело ветки
|
||||
теперь выделяется по балансу фигурных скобок.
|
||||
|
||||
- **Детектор гонок стал обязательным шагом сборки.** Состояние трекера
|
||||
разрешений и мьютекс цикла учёта принадлежат процессу, поэтому их
|
||||
корректность не наблюдаема ни в `go test`, ни в `go vet`. Пропуск при
|
||||
недоступном C-компиляторе не предусмотрен: сборка, молча пропускающая
|
||||
проверку, выдаёт внешне неотличимый production-артефакт.
|
||||
|
||||
### Исправлено — отзыв доступа к VPN (десятый проход)
|
||||
|
||||
- **Отключение пира не отзывало доступ.** Запись `disabled=1` видит только
|
||||
выборка в `Hysteria2Auth`, то есть она закрывает БУДУЩИЕ обращения к
|
||||
HTTP-auth; установленная QUIC-сессия живёт своей жизнью и сама не
|
||||
разрывается. После «Отключить» пир продолжал пользоваться доступом сколько
|
||||
угодно долго, пока не переподключался по своей воле, — а панель показывала
|
||||
его отключённым. Официальная документация Hysteria описывает `/kick` и
|
||||
блокировку в auth backend как пару: по отдельности не работает ни одна
|
||||
половина.
|
||||
|
||||
Появился отдельный примитив разрыва — только официальный Traffic Stats
|
||||
`/kick`, без единой записи в базу. Прежний `Hysteria2Kick` вместе с разрывом
|
||||
проставлял `banned_until`, поэтому воспользоваться им для отключения было
|
||||
нельзя: операция записала бы заодно временную блокировку — другой механизм с
|
||||
другим сроком жизни. (В одиннадцатом проходе он принимает готовые `authId`, а
|
||||
не идентификаторы пиров: удалению и импорту старое значение нужно уже после
|
||||
его исчезновения из базы.)
|
||||
|
||||
Порядок обратному не подлежит: сначала долговременная запись, затем разрыв.
|
||||
При обратном клиент успевает переподключиться в окне между `/kick` и записью.
|
||||
Порядок доказывается снимком состояния базы В МОМЕНТ прихода `/kick` — после
|
||||
операции оба шага уже выполнены и проверять там нечего.
|
||||
|
||||
- **Неудача разрыва не откатывает запрет.** Безопасная половина достигнута, и
|
||||
возвращать пиру полный доступ из-за отказа второго шага нельзя. Частичный
|
||||
результат сообщается отдельным кодом `peer_disconnect_failed`: без него
|
||||
оператор прочитал бы обычную ошибку как «не сработало, состояние прежнее» —
|
||||
вывод, прямо противоположный истине. Панель показывает его предупреждением и
|
||||
обновляет строку.
|
||||
|
||||
- **Временная блокировка отчитывалась отказом, будучи применённой.** `KickPeer`
|
||||
писал `banned_until`, затем звал `Hysteria2Kick`, который писал то же
|
||||
значение второй раз, а до этого мог отказать на проверке состояния службы —
|
||||
не сделав ничего, но уже после первой записи. Обе операции переведены на один
|
||||
примитив.
|
||||
|
||||
- **`Hysteria2IsRunning` убран с путей принятия решений.** `util.Exec`
|
||||
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
|
||||
удалось» в одну ошибку, поэтому его ответ не является основанием ни для
|
||||
отказа операции, ни для её пропуска. Значению осталось место в отображении,
|
||||
где ошибочное «выключено» стоит одной неверной плашки.
|
||||
|
||||
- **Ограничение устройств стало fail-closed.** Отказ `/online` обрабатывался
|
||||
возвратом успеха авторизации, то есть недоступность внутреннего `127.0.0.1`
|
||||
превращала объявленный в панели «Лимит устройств: 3» в безлимит; следом была
|
||||
только строка `warn` в журнале.
|
||||
|
||||
Вторая половина той же дыры была тише: `Hysteria2Online` отдавал ПУСТУЮ КАРТУ
|
||||
БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», — и лимит переставал
|
||||
проверяться вообще без единой записи. Пути разделены по назначению: терпимый
|
||||
для отображения, строгий для решения о доступе.
|
||||
|
||||
Направление выбрано осознанно: запрос авторизации приходит ОТ Hysteria,
|
||||
значит она жива, а её Traffic Stats API слушает loopback внутри того же
|
||||
процесса. У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой
|
||||
отказ затрагивает всех пиров сразу — и пишется уровнем `error`.
|
||||
|
||||
- **Секрет Traffic Stats API читался без проверки на nil** прямо на пути
|
||||
machine-auth, то есть при каждом подключении пира: строка в таблице `config`
|
||||
без значения уронила бы админку паникой на разыменовании.
|
||||
|
||||
- **Ошибка публичного порта больше не называет транспорт.** Публичный endpoint
|
||||
Hysteria — UDP/QUIC, а сообщение утверждало `must be a valid TCP port`.
|
||||
|
||||
### Исправлено — журнал и страницы, которые его показывают
|
||||
|
||||
- **Журнал запросов писал пустой `msg`.** `entry.Info()`, `entry.Warn()` и
|
||||
`entry.Error()` вызывались без аргумента сообщения, и logrus честно
|
||||
записывал `"msg":""` для каждого HTTP-запроса. Пустой столбец на странице
|
||||
системных логов был точным отражением того, что записал backend, а не
|
||||
рассогласованием модели отображения с форматом файла.
|
||||
|
||||
`middleware.RequestLogMessage` собирает строку из тех же величин, что уже
|
||||
лежат в структурных полях. Query-строка в неё не попадает: контракт
|
||||
безопасности сильнее удобства чтения, и тест проверяет обе половины сразу —
|
||||
сообщение непустое И не несёт ни machine token, ни знака `?`.
|
||||
|
||||
- **Страница системных логов не работала, пока нет файла журнала.** Эта ветка
|
||||
отвечала голым массивом вместо `{records, total}`, панель читала
|
||||
`data.records` и передавала `undefined` в таблицу. То есть на
|
||||
свежепоставленном хосте — ровно в сценарии приёмки чистой установки —
|
||||
страница была сломана целиком.
|
||||
|
||||
- **Одна битая строка ломала весь ответ.** При неразбираемой записи
|
||||
выполнялись `vo.Fail(...)` И `continue`: ответ уже записан в поток, цикл идёт
|
||||
дальше, а в конце безусловно выполняется `vo.Success`. Клиент получал два
|
||||
JSON-документа подряд. Строка при этом бьётся штатно — lumberjack ротирует
|
||||
файл, и обрыв последней записи на границе ротации обычен. Теперь такая строка
|
||||
пропускается: остальные записи прочитаны и полезны.
|
||||
|
||||
### Исправлено — панель оператора, второй заход
|
||||
|
||||
- **Быстрого включения/отключения пира в меню не было вовсе.** Сменить
|
||||
состояние можно было только через форму изменения, хотя это самое частое
|
||||
действие оператора.
|
||||
|
||||
- **Меню действий переведено на `command`.** `@click` на каждом
|
||||
`el-dropdown-item` заменён штатным контрактом `el-dropdown`: команда
|
||||
приходит в одно место, и добавить пункт, забыв его подключить, становится
|
||||
невозможно.
|
||||
|
||||
- **Колонки таблиц журнала не задавали ширину.** Без `width`/`min-width`
|
||||
Element Plus делит доступную ширину практически поровну, и сообщение —
|
||||
единственное содержимое журнала — получало треть строки. Служебные колонки
|
||||
зафиксированы, колонка сообщения растягивается за счёт остатка и
|
||||
переносится, а не обрезается многоточием: у Hysteria в `msg` приезжает
|
||||
диагностический JSON.
|
||||
|
||||
- **Обе страницы журнала построены на общем `LogViewer`.** Они были побайтово
|
||||
одинаковы и несли одни и те же дефекты в двух экземплярах; починка в двух
|
||||
местах разошлась бы снова.
|
||||
|
||||
- **Отказ выгрузки журнала не ловился и глушился.** Сетевой запрос стоял ПЕРЕД
|
||||
`try`, поэтому его отказ этим `catch` не ловился вовсе, а сам `catch` был
|
||||
пуст. Оператор нажимал «Экспорт» и не получал ни файла, ни причины.
|
||||
|
||||
- **Сборка ссылки на скачивание сведена в `utils/download.ts`.** Копий было
|
||||
четыре, и все успели разойтись: две ставили запрос вне `try`, третья падала
|
||||
на `split(...)` при отсутствующем `Content-Disposition` — и это исключение
|
||||
тоже глушилось. Четвёртую копию (выгрузка конфига Hysteria) нашёл
|
||||
контрактный тест, потребовавший единственности `createObjectURL`.
|
||||
|
||||
- **Ширина всплывающей подсказки ограничена штатным `popper-style`.** Без
|
||||
ограничения длинный перевод получал естественную ширину popper и
|
||||
растягивался почти на весь экран одной строкой.
|
||||
|
||||
- **Срок временной блокировки называется оператору.** `Date.now() + час` был
|
||||
зашит в обработчик, не подтверждался и не сообщался ни до, ни после; ключи
|
||||
локализации для него существовали и были мёртвыми. Пункты переименованы так,
|
||||
чтобы «временно заблокировать» не путалось с «отключить пир».
|
||||
|
||||
- **Отмена подтверждения перестала быть ошибкой.** `ElMessageBox` отклоняет
|
||||
промис при нажатии «Отмена», и `await ElMessageBox.confirm(...)` без разбора
|
||||
отказа оставлял необработанное отклонение промиса — в четырёх местах страницы
|
||||
пиров и ещё одном в верхней панели.
|
||||
|
||||
- **Подсказка имени пира описывала не действующее правило.** Она обещала
|
||||
«латиница, цифры и дефис», тогда как `service.IsValidPeerName` принимает
|
||||
`a-z A-Z 0-9 !@#$%^&*()_+-=`: подсказка осталась от правила, действовавшего
|
||||
до EX-03, и оператор не имел причин пробовать разрешённые символы. Тест
|
||||
читает набор из серверной константы `PeerNameCharset`.
|
||||
|
||||
- **Типы полей журнала в панели расходились с сервером** (`latencyTime` и
|
||||
`statusCode` объявлены строками при `int64` на сервере). Пока колонки не
|
||||
отображались, расхождение было безвредным.
|
||||
|
||||
- **Диагностика слоя proxy сообщала `err: <nil>` именно при отказе API.**
|
||||
Ветка `err != nil || resp.StatusCode != http.StatusOK` печатала `err`, то
|
||||
есть при ответе Hysteria с кодом, отличным от 200, в журнал уезжала запись
|
||||
без единого факта. Различать эти два случая стало обязательно: на этом пути
|
||||
теперь стоит отзыв доступа.
|
||||
|
||||
- **Отказ показывался дважды, а транспортный — ни разу.** Действия строки пира
|
||||
не объявляли `skipErrorToast`, поэтому после появления собственных сообщений
|
||||
отказ показывался и перехватчиком, и страницей: у частичного результата
|
||||
отзыва доступа это давало предупреждение «состояние применено наполовину» и
|
||||
рядом ошибку — два противоречащих сообщения об одном событии.
|
||||
|
||||
Обратная половина существовала и раньше: флаг гасил не только отказ API, но и
|
||||
ТРАНСПОРТНЫЙ отказ, при том что все объявлявшие его страницы писали в своих
|
||||
обработчиках «транспортный отказ уже показан общим перехватчиком» и молчали.
|
||||
Обрыв соединения при сохранении или удалении пира не показывал оператору
|
||||
ничего. Флаг отнесён только к отказу API — тому, у которого есть конверт с
|
||||
`code` и `errors`, то есть есть что разбирать.
|
||||
|
||||
- **Из globals ESLint убраны `DialogType` и `OptionType`** — их нет ни в
|
||||
`src/types/global.d.ts`, ни где-либо в панели; список приведён к
|
||||
фактическому содержимому файла объявлений. Добавлены `IdDto`, `BaseDto` и
|
||||
`PageVo`, объявленные там на самом деле: `no-undef` не знает о
|
||||
`declare global`, и отсутствующее в списке имя падало ошибкой линтера на
|
||||
совершенно корректном коде.
|
||||
|
||||
### Исправлено — панель оператора
|
||||
|
||||
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
|
||||
«оставьте пустым — сгенерируем автоматически» и отправляла `secret: ""`.
|
||||
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
|
||||
объявлено указателем и указатель не nil: помощник `hasValue` считает
|
||||
указатель на пустую строку «значением». Правило `min=6` применялось к пустой
|
||||
строке и отказывало, а генерация в `CreatePeer` оставалась недостижимой.
|
||||
|
||||
Ловушка закрыта механизмом, а не тегом на одном поле: между разбором тела и
|
||||
проверкой правил появился шаг нормализации DTO (`dto.Normalizable`). Граница
|
||||
проходит по каждому полю отдельно — у `remark` пустая строка означает
|
||||
«убрать пометку», у `disabled` ноль означает «включён», и общее правило
|
||||
«пусто → не задано» молча сломало бы оба.
|
||||
|
||||
Той же ловушкой ломался фильтр списка пиров: `el-input` с крестиком очистки
|
||||
ставит пустую строку, axios сериализует её как `?name=`, и поиск отказывал в
|
||||
один клик по крестику.
|
||||
|
||||
- **Генерация секрета названа явным шагом сервисного слоя.**
|
||||
`service.GeneratePeerSecret` на базе `util.RandomString` (`crypto/rand` с
|
||||
отбрасыванием смещённых байтов) используется и формой, и импортом: пир,
|
||||
созданный панелью, и пир, импортированный без секрета, теперь неотличимы.
|
||||
|
||||
- **Любая ошибка любого поля превращалась в слово `invalid`.** Слой `vo` при
|
||||
этом определял код ответа СРАВНЕНИЕМ текста сообщения с тремя литералами —
|
||||
тот же антипаттерн, который запрещён панели, только на сервере. Ответ об
|
||||
ошибке теперь несёт `errors: [{code, field, message, params}]`; панель
|
||||
выбирает локализованную фразу по коду и подставляет причины под поля формы.
|
||||
Границы числа и границы длины строки различаются кодом, хотя тег валидатора
|
||||
у них один: оператору это разные фразы.
|
||||
|
||||
- **Истечение сессии не обрабатывалось.** Сервер отвечает HTTP 200 на любой
|
||||
отказ, поэтому обработчик ошибок axios для отказов API не вызывался вовсе —
|
||||
а ветка сессии жила именно там; её условие проверяло `code === "A0230"` и
|
||||
поле `msg`, которых в этом API никогда не было. Вдобавок истёкший токен уезжал
|
||||
с кодом системной ошибки. Теперь `ParseToken` возвращает объявленные значения
|
||||
ошибок вместо свежих строк, middleware различает истечение и
|
||||
недействительность через `errors.Is`, а панель показывает диалог и
|
||||
возвращает на форму входа — один раз, даже когда истёкший токен уронил
|
||||
несколько параллельных запросов страницы.
|
||||
|
||||
- **Обработчик транспортных ошибок падал сам.** Он читал `error.response.data`,
|
||||
не проверив `error.response`, и при обрыве соединения подменял настоящую
|
||||
причину `TypeError` внутри себя.
|
||||
|
||||
- **Сброс сессии больше не зовёт `localStorage.clear()`**, который заодно стирал
|
||||
выбранный оператором язык панели.
|
||||
|
||||
- **`id` требовался и в пути, и в теле запроса.** `PeerUpdateDto` встраивал
|
||||
`IdDto` с правилом `required`, хотя значение из тела всё равно затирается
|
||||
значением из пути. Заодно убрана недостижимая запасная ветка `resolveID`,
|
||||
читавшая идентификатор из тела: она вызывала разбор тела, которое обработчик
|
||||
читает следом второй раз, а gin его не буферизует.
|
||||
|
||||
### Исправлено — отрисовка иконок
|
||||
|
||||
- **Контракт `currentColor` был объявлен и не действовал.** `fill: currentcolor`
|
||||
стоял и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`, но восемь из
|
||||
семнадцати ассетов несли литеральный `fill="#000000"` прямо на `<path>`, а
|
||||
атрибут представления перебивает унаследованное CSS-свойство. Под это
|
||||
попадали все семь иконок бокового меню на фоне `#181818`.
|
||||
|
||||
Литеральный цвет убран из ассетов; многоцветные объявлены явным списком;
|
||||
преобразование в `<symbol>` и контракт ассета вынесены в чистый модуль
|
||||
`SvgIcon/symbol.ts`, который можно выполнить вне Vite и DOM — и, значит,
|
||||
проверить. Цвета в рантайме НЕ переписываются: молчаливая нормализация
|
||||
скрывала бы ровно тот дефект, который контракт обязан делать видимым.
|
||||
|
||||
- **У `SvgIcon` убран проп цвета** и атрибут `fill` на `<use>`: он приглашал
|
||||
чинить отрисовку точечно в обход общего контракта.
|
||||
|
||||
### Исправлено — правила имени пира
|
||||
|
||||
- **Два правила на одном поле противоречили друг другу.** Стояли
|
||||
`min=1,max=32` и `validateStr`, требовавший 6-32 символа: имя из трёх
|
||||
символов проходило одно правило и отказывалось на другом. Длина перенесена
|
||||
внутрь одного правила.
|
||||
|
||||
- **Набор символов в слое контроллеров впускал `, - . / : ; <`.** Копия правила
|
||||
несла неэкранированный дефис, из-за чего `+-=` образовывал ДИАПАЗОН; её
|
||||
комментарий при этом утверждал, что набор тот же, что у импорта. Через панель
|
||||
проходило имя `peer/name`, которое импорт того же пира отклонял, — при том что
|
||||
имя уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
|
||||
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
|
||||
дверями в таблицу пиров.
|
||||
|
||||
Набор символов ЛОГИНА администратора сознательно не сужен: он записан явно,
|
||||
но повторяет прежнее фактическое множество. Имя администратора приходит из
|
||||
`HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение правила
|
||||
означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
оператора в панель. Закреплено отдельным тестом, чтобы попытка «навести
|
||||
порядок» роняла сборку, а не вход на живом сервере.
|
||||
|
||||
### Добавлено — атрибуция и контрактные тесты панели
|
||||
|
||||
- **Подпись «Разработано во Flamy»** внизу бокового меню, ссылкой фирменным
|
||||
цветом. Адрес объявлен один раз в `apps/frontend/src/constants/branding.ts` и
|
||||
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API,
|
||||
ни из таблицы `config`. Высота области прокрутки меню вычитает высоту
|
||||
подписи, поэтому пункты меню не могут на неё наехать.
|
||||
|
||||
- **Контрактные тесты панели** (`tools/test/frontend-*.test.ts`) стали
|
||||
обязательным шагом сборки наравне с тестами оркестратора и админки: контракт
|
||||
спрайта иконок, совпадение наборов ключей `ru` и `en`, соответствие кодов
|
||||
ошибок серверным константам, единственность адреса атрибуции.
|
||||
|
||||
Их исполняет уже закреплённый в `versions.env` Bun, а не vitest: jsdom не
|
||||
вычисляет `currentColor` и визуальной корректности всё равно не доказал бы,
|
||||
зато vitest привёл бы в граф `pnpm audit` — а его порог считается по всему
|
||||
lock-файлу frontend — сотню транзитивных зависимостей ради нулевой
|
||||
дополнительной гарантии.
|
||||
|
||||
### Изменено — документация
|
||||
|
||||
- **`docs/` разложена по слоям** вместо плоской кучи из четырнадцати файлов:
|
||||
`architecture/`, `build/`, `runtime/`, `admin/`, `operations/`, `testing/`,
|
||||
`acceptance/`. Двузначный префикс сохранён как стабильный идентификатор
|
||||
документа — под ним на него ссылаются CHANGELOG, релизные гейты и сообщения
|
||||
оркестратора.
|
||||
|
||||
- **`11-testing-and-acceptance.md` (117 КБ, 57 разделов) разбит на пять частей**
|
||||
по слоям, на которых выполняются проверки.
|
||||
|
||||
- **Добавлен `docs/acceptance/`** — отчёты о фактических прогонах приёмки,
|
||||
отдельно от описания самих проверок. Документ проверок переживает релизы;
|
||||
отчёт о прогоне относится к одному артефакту и одному хосту и после
|
||||
публикации не редактируется. Первый отчёт — build/host acceptance
|
||||
`v1.0.0-rc1` на Debian 13 с перечнем найденных дефектов и их закрытия.
|
||||
|
||||
- **Добавлен `docs/admin/15-ui-contracts.md`** — контракты панели, которые не
|
||||
проверяются ни типами, ни сборкой bundle.
|
||||
|
||||
- **Зафиксировано требование к памяти build-хоста:** `govulncheck` строит граф
|
||||
достижимости по всему модулю вместе со stdlib, и на машине с ~1.9 GiB RAM без
|
||||
swap он был убит OOM killer.
|
||||
|
||||
- **Обход документации в приёмке стал рекурсивным.** Плоский шаблон
|
||||
`docs/*.md` после разнесения по каталогам совпадал бы ровно с одним файлом,
|
||||
то есть проверка отчитывалась бы зелёным, не заглянув почти никуда.
|
||||
|
||||
### Исправлено — гейты сборки
|
||||
|
||||
- **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.**
|
||||
@@ -1069,7 +2012,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути
|
||||
установки и данных берутся из конфигурации, а не захардкожены.
|
||||
|
||||
- **`tools/legacy/purge-v0.sh`** и [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) —
|
||||
- **`tools/legacy/purge-v0.sh`** и [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) —
|
||||
явная очистка сервера от предыдущего поколения. По умолчанию скрипт
|
||||
показывает план и ничего не делает; выполнение требует
|
||||
`--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло
|
||||
@@ -1143,7 +2086,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
|
||||
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
|
||||
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
|
||||
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — там это имя чужого
|
||||
[docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — там это имя чужого
|
||||
артефакта, который очистка должна найти.
|
||||
|
||||
- **Индикатор загрузки и legacy-цвета переведены на брендовый токен.**
|
||||
@@ -1210,7 +2153,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
существует. Номера оставшихся миграций сохранены: перенумерация заставила бы
|
||||
их примениться повторно.
|
||||
|
||||
В `docs/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
|
||||
В `docs/operations/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
|
||||
обозначают реальные объекты, которые нужно удалить с сервера. Из остальных
|
||||
v1-доков этот словарь убран.
|
||||
|
||||
@@ -1326,7 +2269,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
|
||||
1. Выпишите с работающего сервера список пиров и их секреты.
|
||||
2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из
|
||||
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md).
|
||||
[docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
|
||||
3. Разверните `1.0.0` на чистом Debian 13 из release-пакета.
|
||||
4. Заведите пиров заново и раздайте новые клиентские ссылки.
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ HY2XS_UI_PUBLIC_ACCESS=false
|
||||
Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в
|
||||
том же состоянии, в котором был**. HY2XS v1 не устанавливается поверх
|
||||
предыдущего поколения и не мигрирует его состояние: очистка старой установки —
|
||||
отдельная явная операция, см. [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md).
|
||||
отдельная явная операция, см. [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
|
||||
|
||||
### 10. Получите bootstrap‑пароль админки
|
||||
|
||||
@@ -634,8 +634,8 @@ hy2xs-orchestrator status \
|
||||
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
|
||||
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
|
||||
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
|
||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
|
||||
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); валидный UTF‑8 в документированном домене `EnvironmentFile=` (в частности, без U+FEFF), без управляющих символов; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
|
||||
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
|
||||
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
|
||||
@@ -647,7 +647,7 @@ hy2xs-orchestrator status \
|
||||
| `HY2XS_HYSTERIA_BIND_HOST` | Bind Hysteria2. В production profile фиксируется на `0.0.0.0` | `0.0.0.0` |
|
||||
| `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` |
|
||||
| `HY2XS_HYSTERIA_AUTH_MODE` | Auth mode Hysteria2. Фиксированное значение production‑профиля | `http` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API | `127.0.0.1` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` | Host trafficStats API. Фиксированное значение production‑профиля: админка обращается к нему только по loopback, поэтому любой другой адрес выключает лимит устройств, учёт трафика и принудительное отключение | `127.0.0.1` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT` | Порт trafficStats API | `36712` |
|
||||
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
|
||||
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
|
||||
@@ -749,7 +749,7 @@ hy2xs-orchestrator reconfigure \
|
||||
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
||||
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
||||
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
||||
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/log/hy2xs/diagnostics` |
|
||||
| `hy2xs-orchestrator diagnostics collect` | Собрать diagnostic bundle в `/var/lib/hy2xs/diagnostics` |
|
||||
| `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
|
||||
|
||||
`repair` без `--allow-partial-state` работает только поверх полностью успешной
|
||||
@@ -766,6 +766,17 @@ hy2xs-orchestrator diagnostics collect \
|
||||
--config /etc/hy2xs/hy2xs.env
|
||||
```
|
||||
|
||||
Бандл не содержит сырых промежуточных копий конфигурации или журналов:
|
||||
редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не
|
||||
декодируется с заменой и не попадает в архив; вместо него записывается
|
||||
безопасная причина пропуска.
|
||||
|
||||
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
|
||||
принадлежит `root:root`, имеет режим `0700` и отделён от
|
||||
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
|
||||
Оркестратор отказывает, если каталог подменён symlink, имеет другого владельца
|
||||
или ослабленные права. Незавершённый staging-каталог после упаковки удаляется.
|
||||
|
||||
## Проверка безопасности после установки
|
||||
|
||||
Минимальный набор проверок:
|
||||
@@ -818,7 +829,7 @@ HY2XS v1 не поддерживает установку поверх и не
|
||||
Что делать:
|
||||
|
||||
1. сохраните нужные данные (база пиров, конфиг) — см.
|
||||
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md);
|
||||
[docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md);
|
||||
2. посмотрите план очистки: `sudo ./purge-v0.sh`;
|
||||
3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`;
|
||||
4. повторите установку.
|
||||
@@ -928,9 +939,9 @@ HY2XS development environment
|
||||
contract: versions.env (HY2XS 1.0.0, release line 1)
|
||||
|
||||
Go:
|
||||
required: 1.26.7
|
||||
required: 1.26.8
|
||||
found: 1.25.6
|
||||
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.7
|
||||
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.8
|
||||
|
||||
Node:
|
||||
required: 24.20.0
|
||||
@@ -997,22 +1008,25 @@ export GITHUB_TOKEN=<token>
|
||||
|
||||
1. проверяет контракт `versions.env` (`verify_versions_contract`);
|
||||
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
|
||||
3. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
||||
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
||||
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
|
||||
6. прогоняет `go vet` и `go test` для HY2XS admin;
|
||||
7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
||||
8. формирует архив и прогоняет acceptance‑проверки.
|
||||
3. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция);
|
||||
4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
|
||||
5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
||||
6. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
|
||||
7. прогоняет `go vet` и `go test` для HY2XS admin;
|
||||
8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
|
||||
9. формирует архив и прогоняет acceptance‑проверки.
|
||||
|
||||
Любой сбой на шагах 1–7 останавливает сборку до создания пакета.
|
||||
Любой сбой на шагах 1–8 останавливает сборку до создания пакета.
|
||||
|
||||
Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка
|
||||
Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка
|
||||
зависимостей: переменной, которая их отключает, не существует. Готовый пакет
|
||||
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
|
||||
утверждение опирается на фактический прогон, а не на намерение.
|
||||
|
||||
Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`,
|
||||
`go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают.
|
||||
`go vet ./...`, `go test ./...` и
|
||||
`bun test tools/test/frontend-sprite.test.ts tools/test/frontend-contract.test.ts`
|
||||
запускаются напрямую и tarball не создают.
|
||||
|
||||
Переменные, управляющие выбором версии Hysteria:
|
||||
|
||||
@@ -1087,9 +1101,16 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
|
||||
├── package/ # skeleton будущего install package
|
||||
├── tools/build/ # production builder и packaging pipeline
|
||||
├── tools/dev/ # doctor: сверка среды разработки с versions.env
|
||||
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria
|
||||
├── tools/test/ # e2e с реальным клиентом Hysteria и контракты панели
|
||||
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
|
||||
├── docs/ # спецификации baseline, тестов и эксплуатации
|
||||
├── docs/ # документация, разложенная по слоям
|
||||
│ ├── architecture/ # baseline-модель и рамки
|
||||
│ ├── build/ # builder layer и состав пакета
|
||||
│ ├── runtime/ # оркестратор, systemd, post-install
|
||||
│ ├── admin/ # HY2XS admin и контракты панели
|
||||
│ ├── operations/ # runbook, разбор отказов, очистка 0.x
|
||||
│ ├── testing/ # набор проверок по слоям
|
||||
│ └── acceptance/ # отчёты о фактических прогонах приёмки
|
||||
├── versions.env # контракт продукта, платформы и toolchain
|
||||
├── CHANGELOG.md
|
||||
├── README.md
|
||||
@@ -1098,6 +1119,8 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
|
||||
|
||||
Каталог `dist/` создаётся builder’ом и не должен храниться в git.
|
||||
|
||||
Точка входа в документацию — [docs/README.md](docs/README.md).
|
||||
|
||||
## Для кого этот проект
|
||||
|
||||
HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу.
|
||||
|
||||
@@ -142,13 +142,17 @@ func classifyServeError(err error) error {
|
||||
return errors.New("start server err")
|
||||
}
|
||||
|
||||
// releaseResource закрывает то, чем владеет сам процесс админки.
|
||||
//
|
||||
// Вызова service.ReleaseHysteria2() здесь больше нет. Функция была пустой
|
||||
// заглушкой `return nil` — остатком модели, в которой панель считала Hysteria
|
||||
// своим подпроцессом и обязана была его отпустить. Жизненным циклом Hysteria
|
||||
// владеет systemd, отпускать админке нечего, и шаг завершения, который ничего
|
||||
// не делает, но выглядит освобождением ресурса, хуже отсутствующего.
|
||||
func releaseResource() {
|
||||
if err := dao.CloseSqliteDB(); err != nil {
|
||||
logrus.Errorf("%v", err)
|
||||
}
|
||||
if err := service.ReleaseHysteria2(); err != nil {
|
||||
logrus.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func initFile() error {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Вход в панель живёт в собственном файле, а не среди обработчиков пиров.
|
||||
//
|
||||
// Это не косметика. Пока Login лежал в controller/peer.go, паника валидатора на
|
||||
// форме входа приходила в journal стеком `controller/peer.go:50`, и первая
|
||||
// гипотеза при разборе RC2 указывала на управление пирами — то есть на
|
||||
// подсистему, не имеющую к отказу никакого отношения. Самая ценная дверь
|
||||
// продукта обязана называться своим именем в стеке.
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Контракт входа в панель.
|
||||
//
|
||||
// Что здесь произошло и почему тестов было мало. В RC2 каждый
|
||||
// `POST /api/auth/login` заканчивался HTTP 500: на LoginDto.Username стоял тег
|
||||
// `validateStr`, правило с таким именем не регистрировалось, и
|
||||
// go-playground/validator ПАНИКОВАЛ до всякой проверки логина и пароля.
|
||||
// Существующие тесты этого не видели, потому что проверяли регексп напрямую —
|
||||
// в обход production-валидатора, — а ни одного запроса к обработчику входа не
|
||||
// делал никто.
|
||||
//
|
||||
// Поэтому проверок здесь три уровня, и каждый ловит свой класс:
|
||||
//
|
||||
// структурный — ни один тег ни в одном DTO не ссылается на незарегистрированное
|
||||
// правило (закрывает КЛАСС, а не найденный экземпляр);
|
||||
// правила — LoginDto проходит через тот самый validate, что и в бою;
|
||||
// HTTP — обработчик отвечает конвертом, а не 500, на любом входе.
|
||||
|
||||
// --------------------------------------------------------------- структура ---
|
||||
|
||||
var validationTagPattern = regexp.MustCompile(`validate:"([^"]*)"`)
|
||||
|
||||
// Ни один тег валидации не имеет права ссылаться на правило, которого нет.
|
||||
//
|
||||
// Это главный барьер файла. Наивная версия — «прогнать нулевое DTO через
|
||||
// validate.Struct» — дефекта RC2 НЕ ловит: цепочка правил поля обрывается на
|
||||
// первом несработавшем, поэтому на пустом Username проверка отказывает по
|
||||
// `required` и до испорченного тега не доходит. Поэтому теги извлекаются из
|
||||
// исходников и каждый предъявляется валидатору отдельно: незарегистрированное
|
||||
// правило паникует ровно так же, как паниковало в бою, но здесь — на сборке.
|
||||
func TestEveryValidationTagIsRegistered(t *testing.T) {
|
||||
root := filepath.Join("..", "model")
|
||||
|
||||
users := map[string]map[string]struct{}{}
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
return nil
|
||||
}
|
||||
source, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, match := range validationTagPattern.FindAllSubmatch(source, -1) {
|
||||
for _, rule := range splitValidationRules(string(match[1])) {
|
||||
if users[rule] == nil {
|
||||
users[rule] = map[string]struct{}{}
|
||||
}
|
||||
users[rule][filepath.ToSlash(path)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать исходники модели: %v", err)
|
||||
}
|
||||
|
||||
// Пустой обход означал бы, что проверка молчит из-за неверного пути, а не
|
||||
// из-за отсутствия дефектов. Число намеренно грубое: тест закрепляет факт
|
||||
// «теги найдены», а не их количество.
|
||||
if len(users) < 5 {
|
||||
t.Fatalf("в %s найдено %d правил валидации — обход не дошёл до DTO", root, len(users))
|
||||
}
|
||||
|
||||
for rule, files := range users {
|
||||
assertRuleIsRegistered(t, rule, files)
|
||||
}
|
||||
}
|
||||
|
||||
// splitValidationRules разбирает содержимое тега на отдельные правила.
|
||||
//
|
||||
// Разделителями считаются и `,`, и `|`: go-playground/validator понимает оба —
|
||||
// первый как «и», второй как «или», — и правило, записанное через `|`, обязано
|
||||
// существовать ровно так же.
|
||||
func splitValidationRules(tag string) []string {
|
||||
var rules []string
|
||||
for _, rule := range strings.FieldsFunc(tag, func(r rune) bool { return r == ',' || r == '|' }) {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" || rule == "-" {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func assertRuleIsRegistered(t *testing.T, rule string, files map[string]struct{}) {
|
||||
t.Helper()
|
||||
|
||||
where := make([]string, 0, len(files))
|
||||
for file := range files {
|
||||
where = append(where, file)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Errorf(
|
||||
"правило %q не зарегистрировано в controller/validator.go, но используется в %s: %v\n"+
|
||||
"незарегистрированный тег означает панику на КАЖДОМ запросе к этой форме",
|
||||
rule, strings.Join(where, ", "), recovered,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// Значение подобрано так, чтобы вопрос был именно о существовании правила:
|
||||
// результат проверки здесь не важен, важно её отсутствие паники.
|
||||
_ = validate.Var("hy2xsadmin", rule)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ правила ---
|
||||
|
||||
func loginDto(username string, password string) dto.LoginDto {
|
||||
return dto.LoginDto{Username: &username, Pass: &password}
|
||||
}
|
||||
|
||||
// normalizedAndValidated повторяет конвейер validateField: нормализация, затем
|
||||
// правила. Проверять правила в обход нормализации бессмысленно — в бою они
|
||||
// видят только приведённый вход.
|
||||
func normalizedAndValidated(login dto.LoginDto) error {
|
||||
login.Normalize()
|
||||
return validate.Struct(&login)
|
||||
}
|
||||
|
||||
// Регрессия RC2: корректный вход не имеет права ни паниковать, ни отказывать.
|
||||
func TestLoginDtoPassesProductionValidator(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка LoginDto паникует: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := normalizedAndValidated(loginDto("hy2xsadmin", "bootstrap-password")); err != nil {
|
||||
t.Fatalf("корректный LoginDto отклонён: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ обязан быть ОТКАЗОМ — значением с причиной, а не паникой.
|
||||
func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
code string
|
||||
field string
|
||||
}{
|
||||
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин длиннее максимума", strings.Repeat("a", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
|
||||
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
|
||||
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "pass"},
|
||||
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", credential.AdminPasswordMaxLength+1), constant.ErrCodeAdminPassword, "pass"},
|
||||
// Укладывается в 64 символа и НЕ укладывается в 72 байта. Прежний
|
||||
// контракт принимал это значение, и отказ приходил из bcrypt уже после
|
||||
// всех проверок — системной ошибкой, а не причиной на поле формы.
|
||||
{"пароль длиннее предела bcrypt", "hy2xsadmin", strings.Repeat("я", credential.AdminPasswordMaxLength), constant.ErrCodeAdminPassword, "pass"},
|
||||
{"управляющий символ в пароле", "hy2xsadmin", "bootstrap\npassword", constant.ErrCodeAdminPassword, "pass"},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка обязана вернуть отказ, а не паниковать: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
err := normalizedAndValidated(loginDto(testCase.username, testCase.password))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный вход принят")
|
||||
}
|
||||
|
||||
described := describeValidationErrors(err)
|
||||
if !hasFieldReason(described, testCase.field, testCase.code) {
|
||||
t.Fatalf("ожидалась причина %q на поле %q, получено %+v", testCase.code, testCase.field, described)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hasFieldReason(described []vo.FieldError, field string, code string) bool {
|
||||
for _, reason := range described {
|
||||
if reason.Field == field && reason.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Причина отказа по набору символов обязана НАЗЫВАТЬ набор.
|
||||
//
|
||||
// Оператору негде посмотреть, какие символы допустимы в логине: значение
|
||||
// приходит из hy2xs.env, а панель показывает то, что ответил сервер. Прежнее
|
||||
// «содержит недопустимые символы» вдобавок описывало и отказ по длине.
|
||||
func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
err := normalizedAndValidated(loginDto("админ12", "bootstrap-password"))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный логин принят")
|
||||
}
|
||||
|
||||
for _, reason := range describeValidationErrors(err) {
|
||||
if reason.Code != constant.ErrCodeCredentialStr {
|
||||
continue
|
||||
}
|
||||
if reason.Params["charset"] != credential.AdminUsernameCharset {
|
||||
t.Errorf("причина не называет набор символов: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) {
|
||||
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) {
|
||||
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- контракты ---
|
||||
|
||||
// passwordFields — все поля продукта, несущие пароль администратора.
|
||||
var passwordFields = []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
field string
|
||||
}{
|
||||
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
|
||||
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
|
||||
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
|
||||
}
|
||||
|
||||
// Правило пароля ОДНО, и границы живут внутри него.
|
||||
//
|
||||
// Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы
|
||||
// числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли
|
||||
// неполное правило: тег считает символы Unicode, а bcrypt считает байты, и
|
||||
// границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не
|
||||
// равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям
|
||||
// по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule).
|
||||
func TestPasswordLengthLivesInsideItsRule(t *testing.T) {
|
||||
for _, subject := range passwordFields {
|
||||
field, ok := subject.typ.FieldByName(subject.field)
|
||||
if !ok {
|
||||
t.Fatalf("%s: поле не найдено", subject.name)
|
||||
}
|
||||
|
||||
rules := splitValidationRules(field.Tag.Get("validate"))
|
||||
|
||||
for _, rule := range rules {
|
||||
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
|
||||
t.Errorf(
|
||||
"%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+
|
||||
"тег умеет считать только символы, а у пароля есть ещё граница в байтах",
|
||||
subject.name, rule,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var hasPasswordRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "adminPassword" {
|
||||
hasPasswordRule = true
|
||||
}
|
||||
}
|
||||
if !hasPasswordRule {
|
||||
t.Errorf("%s: проверяется правилами %v — среди них нет adminPassword", subject.name, rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Все поля пароля проверяются ТЕМ ЖЕ правилом, что и bootstrap-установка.
|
||||
//
|
||||
// Проверка идёт через production-валидатор на граничных значениях: совпадения
|
||||
// имён тегов мало, потому что именно расхождение реализаций за одинаковыми
|
||||
// именами и было исходным дефектом.
|
||||
func TestEveryPasswordFieldFollowsTheContract(t *testing.T) {
|
||||
samples := []string{
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde ",
|
||||
"abcde\n",
|
||||
}
|
||||
|
||||
for _, subject := range passwordFields {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
rules := strings.Join(splitValidationRules(field.Tag.Get("validate")), ",")
|
||||
rules = strings.ReplaceAll(rules, "required,", "")
|
||||
|
||||
for _, sample := range samples {
|
||||
expected := credential.IsValidAdminPassword(sample)
|
||||
if got := validate.Var(sample, rules) == nil; got != expected {
|
||||
t.Errorf(
|
||||
"%s: значение из %d символов / %d байт принято=%v, контракт=%v",
|
||||
subject.name, len([]rune(sample)), len(sample), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене.
|
||||
//
|
||||
// Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому
|
||||
// ограничение набора не защищает ничего и умеет только отвергнуть пароль,
|
||||
// который сервер принял бы. Панель обязана держаться этого же контракта, и
|
||||
// проверка существует, чтобы правило не «навели» обратно.
|
||||
func TestPasswordHasNoCharsetRule(t *testing.T) {
|
||||
for _, subject := range passwordFields {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
if rule == "credentialStr" || rule == "peerName" {
|
||||
t.Errorf("%s: на пароль повешено правило набора символов %q", subject.name, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина логина живёт ВНУТРИ правила, а не рядом с ним.
|
||||
//
|
||||
// Два правила длины на одном поле уже приводили к отказу, который невозможно
|
||||
// объяснить оператору: `min=1,max=32` вместе с правилом «6-32» принимали и
|
||||
// отвергали одно и то же значение, а панель показывала «invalid».
|
||||
func TestUsernameLengthLivesInsideItsRule(t *testing.T) {
|
||||
field, ok := reflect.TypeOf(dto.LoginDto{}).FieldByName("Username")
|
||||
if !ok {
|
||||
t.Fatal("LoginDto.Username не найдено")
|
||||
}
|
||||
|
||||
rules := splitValidationRules(field.Tag.Get("validate"))
|
||||
for _, rule := range rules {
|
||||
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
|
||||
t.Errorf("на логине отдельная граница %q: длина принадлежит правилу credentialStr", rule)
|
||||
}
|
||||
}
|
||||
|
||||
var hasCredentialRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "credentialStr" {
|
||||
hasCredentialRule = true
|
||||
}
|
||||
}
|
||||
if !hasCredentialRule {
|
||||
t.Fatalf("логин проверяется правилами %v — среди них нет credentialStr", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- HTTP ---
|
||||
|
||||
func newAuthTestDB(t *testing.T, username string, password string) int64 {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||
|
||||
// Токен подписывается ключом из таблицы config. Без него успешный вход
|
||||
// отказал бы системной ошибкой, и тест перестал бы отличать работающий
|
||||
// вход от неработающего.
|
||||
if err := dao.UpsertConfigValue(constant.JwtSecret, "test-jwt-secret"); err != nil {
|
||||
t.Fatalf("не удалось записать ключ подписи: %v", err)
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось захешировать пароль: %v", err)
|
||||
}
|
||||
|
||||
name := username
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
force := int64(0)
|
||||
changedAt := time.Now().UnixMilli()
|
||||
id, err := dao.SaveAdminUser(entity.AdminUser{
|
||||
Username: &name,
|
||||
PasswordHash: &hash,
|
||||
Status: &status,
|
||||
TokenVersion: &tokenVersion,
|
||||
ForcePasswordChange: &force,
|
||||
PasswordChangedAt: &changedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось создать администратора: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// postLoginRaw отправляет тело КАК ЕСТЬ через маршрут боевой конфигурации.
|
||||
//
|
||||
// `recovery` выбирает, стоит ли перед обработчиком gin.Recovery. Обе ветки
|
||||
// нужны: без него паника роняет тест и называет причину, с ним воспроизводится
|
||||
// ровно то, что видел оператор RC2, — HTTP 500 вместо конверта.
|
||||
func postLoginRaw(t *testing.T, body []byte, recovery bool) (int, apiResult) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
engine := gin.New()
|
||||
if recovery {
|
||||
engine.Use(gin.Recovery())
|
||||
}
|
||||
engine.POST("/api/auth/login", Login)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if recorder.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(recorder.Body.Bytes(), &result)
|
||||
}
|
||||
return recorder.Code, result
|
||||
}
|
||||
|
||||
func postLogin(t *testing.T, body any) (int, apiResult) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
return postLoginRaw(t, payload, false)
|
||||
}
|
||||
|
||||
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
|
||||
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
status, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("вход ответил HTTP %d, ожидался 200", status)
|
||||
}
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
var issued struct {
|
||||
TokenType string `json:"tokenType"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Data, &issued); err != nil {
|
||||
t.Fatalf("ответ не содержит токена: %s", string(result.Data))
|
||||
}
|
||||
if strings.TrimSpace(issued.AccessToken) == "" {
|
||||
t.Fatal("выдан пустой токен")
|
||||
}
|
||||
if issued.TokenType != constant.TokenType {
|
||||
t.Fatalf("тип токена %q, ожидался %q", issued.TokenType, constant.TokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// Повреждённые байты и непарные surrogate escape не имеют права превратиться
|
||||
// в U+FFFD до проверки пароля. Сам U+FFFD остаётся обычным допустимым символом.
|
||||
func TestLoginEndpointRejectsLossyJSONBeforeAuthentication(t *testing.T) {
|
||||
password := "abcde" + string(rune(0xFFFD))
|
||||
id := newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
for _, body := range [][]byte{
|
||||
[]byte{'{', '"', 'u', 's', 'e', 'r', 'n', 'a', 'm', 'e', '"', ':', '"', 'h', 'y', '2', 'x', 's', 'a', 'd', 'm', 'i', 'n', '"', ',', '"', 'p', 'a', 's', 's', '"', ':', '"', 0xFF, '"', '}'},
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uD800"}`),
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uDC00"}`),
|
||||
} {
|
||||
status, result := postLoginRaw(t, body, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("повреждённое тело дало HTTP %d", status)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("повреждённое тело не отклонено как invalid: %+v", result)
|
||||
}
|
||||
var found bool
|
||||
for _, reason := range result.Errors {
|
||||
found = found || reason.Code == constant.ErrCodeBodyInvalid
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("нет причины %q: %+v", constant.ErrCodeBodyInvalid, result)
|
||||
}
|
||||
}
|
||||
admin, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось проверить состояние администратора: %v", err)
|
||||
}
|
||||
if admin.LastLoginAt != nil && *admin.LastLoginAt != 0 {
|
||||
t.Fatalf("отклонённое JSON-тело изменило last_login_at: %d", *admin.LastLoginAt)
|
||||
}
|
||||
|
||||
_, valid := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if valid.Code != constant.CodeSuccess {
|
||||
t.Fatalf("настоящий U+FFFD ошибочно отклонён: %+v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
|
||||
//
|
||||
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
|
||||
// ошибку транспорта, и панель не могла сказать о ней ничего осмысленного.
|
||||
func TestLoginEndpointNeverAnswersServerErrorBehindRecovery(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
bodies := [][]byte{
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"","pass":""}`),
|
||||
[]byte(`{"username":"админ12","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"hy2xsadmin"}`),
|
||||
[]byte(`{}`),
|
||||
[]byte(`{"username":123,"pass":false}`),
|
||||
[]byte(`не json вовсе`),
|
||||
[]byte(``),
|
||||
}
|
||||
|
||||
for _, body := range bodies {
|
||||
status, result := postLoginRaw(t, body, true)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("тело %q дало HTTP %d: обработчик обязан отвечать конвертом, а не отказом транспорта", string(body), status)
|
||||
continue
|
||||
}
|
||||
if result.Code != constant.CodeSuccess && len(result.Errors) == 0 && strings.TrimSpace(result.Message) == "" {
|
||||
t.Errorf("тело %q дало отказ без причины: %+v", string(body), result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Неверный пароль — доменный отказ с кодом, а не системная ошибка.
|
||||
func TestLoginEndpointReportsInvalidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
})
|
||||
|
||||
if result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
if _, found := errorFor(t, result, ""); !found {
|
||||
t.Fatalf("отказ без причины: %+v", result)
|
||||
}
|
||||
var hasCode bool
|
||||
for _, reason := range result.Errors {
|
||||
if reason.Code == constant.ErrCodeInvalidCredentials {
|
||||
hasCode = true
|
||||
}
|
||||
}
|
||||
if !hasCode {
|
||||
t.Fatalf("отказ не помечен кодом %q: %+v", constant.ErrCodeInvalidCredentials, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Несуществующий администратор неотличим от неверного пароля.
|
||||
//
|
||||
// Иначе форма входа превращается в способ проверять существование имён.
|
||||
func TestLoginEndpointDoesNotRevealWhetherAccountExists(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, missing := postLogin(t, map[string]any{"username": "otheradmin", "pass": "bootstrap-password"})
|
||||
_, wrong := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "wrong-password"})
|
||||
|
||||
if missing.Code != wrong.Code || missing.Message != wrong.Message {
|
||||
t.Fatalf("ответы различимы: несуществующий %+v против неверного пароля %+v", missing, wrong)
|
||||
}
|
||||
}
|
||||
|
||||
// Логин из bootstrap-admin.secret регулярно приезжает с пробелом или переводом
|
||||
// строки на конце. Раньше это давало «неверный логин или пароль».
|
||||
func TestLoginEndpointTrimsSurroundingWhitespaceInUsername(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": " hy2xsadmin\n",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("логин с окружающими пробелами отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится: его набор символов не ограничен, и пробел по краям —
|
||||
// часть значения, а не мусор ввода.
|
||||
func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password ")
|
||||
|
||||
_, exact := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password "})
|
||||
if exact.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль с пробелом на конце отклонён: %+v", exact)
|
||||
}
|
||||
|
||||
_, trimmed := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password"})
|
||||
if trimmed.Code == constant.CodeSuccess {
|
||||
t.Fatal("пароль обрезан по краям: вход выполнен не тем значением, которое ввёл оператор")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль длиной в 64 символа, назначенный формой смены, обязан пускать в панель.
|
||||
//
|
||||
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
|
||||
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
|
||||
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
password := strings.Repeat("a", credential.AdminPasswordMaxLength)
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль предельной длины отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль, упирающийся в предел bcrypt ровно, обязан работать целиком: он
|
||||
// назначается формой смены пароля и вводится формой входа.
|
||||
//
|
||||
// 36 кириллических букв — это ровно 72 байта, то есть последнее значение,
|
||||
// которое GenerateFromPassword принимает. Проверяется весь путь: хеширование
|
||||
// при создании учётной записи и вход этим же значением.
|
||||
func TestLoginEndpointAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
|
||||
password := strings.Repeat("я", 36)
|
||||
if len(password) != credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль в 72 байта отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// А на символ длиннее — отказ ПРАВИЛА, с причиной на поле, а не системная
|
||||
// ошибка из bcrypt.
|
||||
//
|
||||
// Ровно этого не было: 37 кириллических букв (74 байта) укладывались в 64
|
||||
// символа, проходили все проверки и упирались в ErrPasswordTooLong уже внутри
|
||||
// хеширования. На форме смены пароля оператор видел «system error».
|
||||
func TestLoginEndpointRejectsPasswordOverBcryptByteLimitWithFieldReason(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
password := strings.Repeat("я", 37)
|
||||
if len(password) <= credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт, ожидалось больше %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
status, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("HTTP %d: обработчик обязан отвечать конвертом", status)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("код ответа %d, ожидался отказ проверки данных %d: %+v", result.Code, constant.CodeInvalidError, result)
|
||||
}
|
||||
var hasReason bool
|
||||
for _, reason := range result.Errors {
|
||||
if reason.Field == "pass" && reason.Code == constant.ErrCodeAdminPassword {
|
||||
hasReason = true
|
||||
}
|
||||
}
|
||||
if !hasReason {
|
||||
t.Fatalf("отказ не назван причиной %q на поле pass: %+v", constant.ErrCodeAdminPassword, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
|
||||
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
|
||||
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
before, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if before.LastLoginAt != nil && *before.LastLoginAt != 0 {
|
||||
t.Fatalf("подготовка: отметка о входе уже проставлена (%d)", *before.LastLoginAt)
|
||||
}
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
}); result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось перечитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt == nil || *after.LastLoginAt == 0 {
|
||||
t.Fatal("время входа не записано")
|
||||
}
|
||||
}
|
||||
|
||||
// Неудачная попытка отметку о входе НЕ ставит.
|
||||
func TestFailedLoginDoesNotRecordLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
}); result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt != nil && *after.LastLoginAt != 0 {
|
||||
t.Fatalf("неудачная попытка проставила время входа: %d", *after.LastLoginAt)
|
||||
}
|
||||
}
|
||||
@@ -187,13 +187,34 @@ func ListConfig(c *gin.Context) {
|
||||
vo.Success(configVos, c)
|
||||
}
|
||||
|
||||
// GetHysteria2Config отдаёт панели конфигурацию в терминах production-профиля.
|
||||
//
|
||||
// Что было: `vo.Success(service.GetHysteria2Config(), c)` — внутренняя модель
|
||||
// серверного конфига сериализовалась в браузер целиком. У этого было два
|
||||
// следствия.
|
||||
//
|
||||
// Первое — секреты. `auth` и `trafficStats.secret` закрыты `json:"-"`, но
|
||||
// пароль обфускации, токены ACME DNS (`acme.dns.config`), учётные данные
|
||||
// outbound-прокси и masquerade уезжали в открытом виде. Скачиваемая выгрузка
|
||||
// того же конфига их вырезает, и читающий экран не имеет права быть щедрее.
|
||||
// Привилегий это не повышало — маршрут под admin JWT, — но и нужды в этих
|
||||
// значениях у read-only экрана нет.
|
||||
//
|
||||
// Второе — смысл ответа. Модель отдавала «все известные HY2XS поля», а панель
|
||||
// накладывала их на полный объект дефолтов, поэтому экран показывал не файл, а
|
||||
// файл, дополненный выдумкой: отсутствующий `trafficStats` превращался в
|
||||
// `:9999`. Ровно тот дрейф, который экран обязан показывать, он и скрывал.
|
||||
//
|
||||
// Теперь ответ описывает профиль явно, отличает «не задано» от значения и
|
||||
// отдельно перечисляет секции вне профиля. Полный документ доступен
|
||||
// санитизированной выгрузкой ниже.
|
||||
func GetHysteria2Config(c *gin.Context) {
|
||||
config, err := service.GetHysteria2Config()
|
||||
profile, err := service.BuildHysteria2Profile()
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(config, c)
|
||||
vo.Success(profile, c)
|
||||
}
|
||||
|
||||
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ type apiResult struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Errors []vo.FieldError `json:"errors"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// failService переводит отказ сервисного слоя в ответ панели.
|
||||
//
|
||||
// Доменный отказ несёт код и, если он относится к полю формы, имя этого поля
|
||||
// (см. service.PeerError). Всё остальное остаётся отказом уровня операции с
|
||||
// человеческим сообщением — панель покажет его как есть, но разбирать текст ей
|
||||
// при этом не придётся ни в одном известном случае.
|
||||
func failService(err error, c *gin.Context) {
|
||||
var peerErr *service.PeerError
|
||||
if errors.As(err, &peerErr) {
|
||||
vo.FailField(peerErr.Code, peerErr.Field, peerErr.Message, c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func Hysteria2Auth(c *gin.Context) {
|
||||
var req dto.Hysteria2AuthDto
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := strictBindJSON(c, &req); err != nil {
|
||||
vo.Hysteria2AuthBadRequest(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const jsonBodyMaxBytes = 1024 * 1024
|
||||
|
||||
var errJSONBodyTooLarge = errors.New("тело JSON превышает 1 МиБ")
|
||||
|
||||
// strictBindJSON не позволяет стандартному encoding/json молча заменить
|
||||
// повреждённый UTF-8 или непарный UTF-16 surrogate escape на U+FFFD.
|
||||
//
|
||||
// Это критично для секретов: U+FFFD сам по себе разрешён, поэтому без этой
|
||||
// границы другое байтовое значение могло аутентифицироваться как пароль,
|
||||
// который фактически хранится в базе. После проверки тело восстанавливается и
|
||||
// передаётся штатному Gin binder, чтобы сохранить действующий API-контракт.
|
||||
func strictBindJSON(c *gin.Context, dst any) error {
|
||||
body := c.Request.Body
|
||||
if body == nil {
|
||||
return errors.New("пустое тело JSON")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(body, jsonBodyMaxBytes+1))
|
||||
_ = body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось прочитать тело JSON: %w", err)
|
||||
}
|
||||
if len(raw) > jsonBodyMaxBytes {
|
||||
return errJSONBodyTooLarge
|
||||
}
|
||||
if err := validateStrictJSONEncoding(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.ShouldBindJSON(dst)
|
||||
}
|
||||
|
||||
// validateStrictJSONEncoding проверяет только границу кодировки. Синтаксис,
|
||||
// типы и структуру затем проверяет encoding/json; здесь отдельно закрывается
|
||||
// его документированное replacement-поведение.
|
||||
func validateStrictJSONEncoding(raw []byte) error {
|
||||
if !utf8.Valid(raw) {
|
||||
return errors.New("тело JSON содержит некорректный UTF-8")
|
||||
}
|
||||
|
||||
inString := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
switch raw[i] {
|
||||
case '"':
|
||||
inString = !inString
|
||||
case '\\':
|
||||
if !inString {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return errors.New("незавершённая escape-последовательность JSON")
|
||||
}
|
||||
if raw[i] != 'u' {
|
||||
continue
|
||||
}
|
||||
|
||||
code, ok := parseJSONHex4(raw, i+1)
|
||||
if !ok {
|
||||
return errors.New("некорректная Unicode escape-последовательность JSON")
|
||||
}
|
||||
i += 4
|
||||
switch {
|
||||
case code >= 0xD800 && code <= 0xDBFF:
|
||||
// Старший суррогат допустим только как первая половина пары и
|
||||
// только в соседней escape-последовательности \uXXXX.
|
||||
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
||||
return errors.New("непарный старший UTF-16 суррогат в JSON")
|
||||
}
|
||||
low, lowOK := parseJSONHex4(raw, i+3)
|
||||
if !lowOK || low < 0xDC00 || low > 0xDFFF {
|
||||
return errors.New("непарный старший UTF-16 суррогат в JSON")
|
||||
}
|
||||
i += 6
|
||||
case code >= 0xDC00 && code <= 0xDFFF:
|
||||
return errors.New("непарный младший UTF-16 суррогат в JSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSONHex4(raw []byte, start int) (uint16, bool) {
|
||||
if start < 0 || start+4 > len(raw) {
|
||||
return 0, false
|
||||
}
|
||||
var value uint16
|
||||
for _, character := range raw[start : start+4] {
|
||||
value <<= 4
|
||||
switch {
|
||||
case character >= '0' && character <= '9':
|
||||
value += uint16(character - '0')
|
||||
case character >= 'a' && character <= 'f':
|
||||
value += uint16(character-'a') + 10
|
||||
case character >= 'A' && character <= 'F':
|
||||
value += uint16(character-'A') + 10
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package controller
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateStrictJSONEncoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{"обычный UTF-8", []byte(`{"password":"пароль"}`), false},
|
||||
{"настоящий U+FFFD", []byte(`{"password":"abcde�"}`), false},
|
||||
{"валидная surrogate pair", []byte(`{"value":"\uD83D\uDE00"}`), false},
|
||||
{"экранированный обратный слеш", []byte(`{"value":"\\uD800"}`), false},
|
||||
{"байт FF", []byte{'{', '"', 'x', '"', ':', '"', 0xFF, '"', '}'}, true},
|
||||
{"оборванный UTF-8", []byte{'{', '"', 'x', '"', ':', '"', 0xC3, '"', '}'}, true},
|
||||
{"UTF-8 суррогата", []byte{'{', '"', 'x', '"', ':', '"', 0xED, 0xA0, 0x80, '"', '}'}, true},
|
||||
{"непарный старший", []byte(`{"value":"\uD800"}`), true},
|
||||
{"старший перед обычным", []byte(`{"value":"\uD800\u0041"}`), true},
|
||||
{"непарный младший", []byte(`{"value":"\uDC00"}`), true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotErr := validateStrictJSONEncoding(test.body) != nil
|
||||
if gotErr != test.wantErr {
|
||||
t.Fatalf("ошибка=%v, ожидалась=%v", gotErr, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,20 @@ func LogSystem(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
exists := util.Exists(constant.SystemLogPath)
|
||||
logSystemVos := make([]vo.LogSystemVo, 0)
|
||||
if !exists {
|
||||
vo.Success(logSystemVos, c)
|
||||
|
||||
// Форма ответа одна для ВСЕХ веток: {records, total}.
|
||||
//
|
||||
// Раньше ветка «файла ещё нет» отвечала голым массивом:
|
||||
//
|
||||
// vo.Success(logSystemVos, c)
|
||||
//
|
||||
// Панель читает `data.records`, поэтому получала undefined и передавала
|
||||
// его в `:data` таблицы. То есть на свежепоставленном хосте — до первой
|
||||
// записи в журнал — страница системных логов была сломана целиком, и
|
||||
// именно этот сценарий воспроизводится на каждой приёмке чистой установки.
|
||||
if !util.Exists(constant.SystemLogPath) {
|
||||
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{LogSystemVos: logSystemVos, Total: 0}, c)
|
||||
return
|
||||
}
|
||||
numLine := 0
|
||||
@@ -38,9 +48,26 @@ func LogSystem(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
logSystemVo := vo.LogSystemVo{}
|
||||
err := json.Unmarshal([]byte(line), &logSystemVo)
|
||||
if err != nil {
|
||||
vo.Fail("Unable to unmarshal log data", c)
|
||||
// Неразбираемая строка ПРОПУСКАЕТСЯ, а не отказывает запросу.
|
||||
//
|
||||
// Было так:
|
||||
//
|
||||
// if err != nil {
|
||||
// vo.Fail("Unable to unmarshal log data", c)
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// — ответ записывался в поток и цикл шёл дальше, а в конце
|
||||
// безусловно выполнялся vo.Success. Клиент получал два JSON-документа
|
||||
// подряд, то есть невалидный ответ, и gin ругался на повторную запись
|
||||
// заголовков. Достаточно было ОДНОЙ битой строки, чтобы страница
|
||||
// перестала открываться целиком.
|
||||
//
|
||||
// Строка при этом бьётся штатно: lumberjack ротирует файл, и обрыв
|
||||
// последней записи на границе ротации — обычное событие, а не признак
|
||||
// повреждения журнала. Отказывать из-за него всей выдаче нечем
|
||||
// оправдать: остальные записи прочитаны и полезны.
|
||||
if err := json.Unmarshal([]byte(line), &logSystemVo); err != nil {
|
||||
continue
|
||||
}
|
||||
// Собственный журнал санитизируется так же, как чужой.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Форма ответа страницы системных логов.
|
||||
//
|
||||
// Панель читает `data.records` и передаёт результат прямо в `:data` таблицы.
|
||||
// Поэтому ответ обязан нести records/total на КАЖДОЙ ветке — иначе таблица
|
||||
// получает undefined и страница ломается целиком.
|
||||
|
||||
type logPageEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Data *struct {
|
||||
Records []map[string]any `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// requestSystemLog выполняет запрос к странице системных логов, подставив
|
||||
// вместо журнала переданное содержимое. Пустой content означает «файла нет».
|
||||
func requestSystemLog(t *testing.T, content *string) (logPageEnvelope, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "hy2xs-admin.log")
|
||||
if content != nil {
|
||||
if err := os.WriteFile(path, []byte(*content), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать тестовый журнал: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
previous := constant.SystemLogPath
|
||||
constant.SystemLogPath = path
|
||||
t.Cleanup(func() { constant.SystemLogPath = previous })
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/log/logSystem", LogSystem)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/log/logSystem?numLine=100", nil))
|
||||
|
||||
raw := recorder.Body.String()
|
||||
var envelope logPageEnvelope
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
|
||||
t.Fatalf("ответ не разбирается как один JSON-документ: %v\nтело: %s", err, raw)
|
||||
}
|
||||
return envelope, raw
|
||||
}
|
||||
|
||||
// Регрессия LOG-04. Ветка «файла ещё нет» отвечала ГОЛЫМ МАССИВОМ:
|
||||
//
|
||||
// vo.Success(logSystemVos, c)
|
||||
//
|
||||
// Панель получала `data.records === undefined` и передавала undefined в
|
||||
// таблицу. То есть на свежепоставленном хосте — до первой записи в журнал —
|
||||
// страница системных логов не работала вовсе, а это ровно тот сценарий,
|
||||
// который проверяется на приёмке каждой чистой установки.
|
||||
func TestLogSystemKeepsPageShapeWhenFileIsMissing(t *testing.T) {
|
||||
envelope, raw := requestSystemLog(t, nil)
|
||||
|
||||
if envelope.Type != "ok" {
|
||||
t.Fatalf("отсутствие журнала признано отказом: %s", raw)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||
}
|
||||
if envelope.Data.Records == nil {
|
||||
t.Fatalf("ответ не несёт поля records: %s", raw)
|
||||
}
|
||||
if len(envelope.Data.Records) != 0 || envelope.Data.Total != 0 {
|
||||
t.Fatalf("ожидалась пустая страница, получено %+v", *envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия LOG-05. При неразбираемой строке вызывался vo.Fail(...) и следом
|
||||
// `continue`: ответ уже записан в поток, цикл идёт дальше, в конце безусловно
|
||||
// выполняется vo.Success. Клиент получал два JSON-документа подряд — то есть
|
||||
// невалидный ответ, — и одной битой строки хватало, чтобы страница перестала
|
||||
// открываться целиком.
|
||||
//
|
||||
// Строка бьётся штатно: lumberjack ротирует файл, и обрыв последней записи на
|
||||
// границе ротации — обычное событие.
|
||||
func TestLogSystemSkipsBrokenLineWithoutBreakingResponse(t *testing.T) {
|
||||
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","time":"2026-09-01 10:00:00"}
|
||||
{"level":"info","msg":"обрыв записи на границе ротации
|
||||
{"level":"warning","msg":"PATCH /api/peers/12 → 400 (4 ms)","time":"2026-09-01 10:00:01"}
|
||||
`
|
||||
envelope, raw := requestSystemLog(t, &content)
|
||||
|
||||
if envelope.Type != "ok" {
|
||||
t.Fatalf("битая строка отказала всей выдаче: %s", raw)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("ответ не несёт объекта страницы: %s", raw)
|
||||
}
|
||||
if len(envelope.Data.Records) != 2 {
|
||||
t.Fatalf("ожидались две читаемые записи, получено %d: %+v", len(envelope.Data.Records), envelope.Data.Records)
|
||||
}
|
||||
for _, record := range envelope.Data.Records {
|
||||
if message, _ := record["msg"].(string); message == "" {
|
||||
t.Errorf("запись пришла с пустым msg: %+v", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Читаемый журнал доезжает до панели в той же форме.
|
||||
func TestLogSystemReturnsRecordsForReadableFile(t *testing.T) {
|
||||
content := `{"level":"info","msg":"GET /api/peers → 200 (7 ms)","reqMethod":"GET","reqPath":"/api/peers","statusCode":200,"latencyTime":7,"time":"2026-09-01 10:00:00"}
|
||||
`
|
||||
envelope, raw := requestSystemLog(t, &content)
|
||||
|
||||
if envelope.Data == nil || len(envelope.Data.Records) != 1 {
|
||||
t.Fatalf("читаемая запись не доехала до панели: %s", raw)
|
||||
}
|
||||
record := envelope.Data.Records[0]
|
||||
if message, _ := record["msg"].(string); message == "" {
|
||||
t.Fatalf("msg потерян при отдаче: %+v", record)
|
||||
}
|
||||
if path, _ := record["reqPath"].(string); path != "/api/peers" {
|
||||
t.Fatalf("структурные поля не доехали: %+v", record)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -18,31 +19,31 @@ import (
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
|
||||
//
|
||||
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
|
||||
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
|
||||
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
|
||||
// вызывала validateField, который читает тело запроса, а обработчик следом
|
||||
// читает то же тело второй раз — gin его не буферизует, и второй разбор
|
||||
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
|
||||
// когда понадобился бы.
|
||||
func resolveID(c *gin.Context) (int64, error) {
|
||||
if raw := strings.TrimSpace(c.Param("id")); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err == nil && parsed > 0 {
|
||||
return parsed, nil
|
||||
}
|
||||
raw := strings.TrimSpace(c.Param("id"))
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
vo.FailValidation(
|
||||
"идентификатор пира в адресе некорректен",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeBodyInvalid,
|
||||
Field: "id",
|
||||
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return 0, errors.New(constant.ErrCodeBodyInvalid)
|
||||
}
|
||||
idDto, err := validateField(c, dto.IdDto{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return *idDto.Id, nil
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func PagePeer(c *gin.Context) {
|
||||
@@ -50,12 +51,12 @@ func PagePeer(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
records, total, err := service.PagePeer(peerPageDto)
|
||||
records, total, onlineState, err := service.PagePeer(peerPageDto)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.PeerPageVo{Records: records, Total: total}, c)
|
||||
vo.Success(vo.PeerPageVo{Records: records, Total: total, OnlineState: onlineState}, c)
|
||||
}
|
||||
|
||||
func SavePeer(c *gin.Context) {
|
||||
@@ -65,7 +66,7 @@ func SavePeer(c *gin.Context) {
|
||||
}
|
||||
peerVo, err := service.CreatePeer(peerSaveDto)
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(peerVo, c)
|
||||
@@ -76,8 +77,14 @@ func DeletePeer(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// failService, а не vo.Fail: удаление умеет завершиться ЧАСТИЧНО — пир
|
||||
// отключён в базе, но завершить его активную сессию не удалось, поэтому
|
||||
// строка намеренно оставлена на месте. Через vo.Fail этот результат уехал
|
||||
// бы панели неотличимо от полного отказа, и оператор сделал бы неверный
|
||||
// вывод: «удаление не сработало, пир как был», — тогда как доступ уже
|
||||
// закрыт, а строка ждёт повторной попытки.
|
||||
if err = service.DeletePeer(id); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
@@ -100,12 +107,12 @@ func UpdatePeer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if taken {
|
||||
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
|
||||
failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
@@ -158,7 +165,15 @@ func ImportPeer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
vo.FailValidation(
|
||||
"импорт принимает только файлы .json",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeImportFileExtension,
|
||||
Field: "file",
|
||||
Message: "импорт принимает только файлы .json",
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -174,6 +189,10 @@ func ImportPeer(c *gin.Context) {
|
||||
vo.Fail("the file is too big", c)
|
||||
return
|
||||
}
|
||||
if err = validateStrictJSONEncoding(content); err != nil {
|
||||
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
|
||||
return
|
||||
}
|
||||
|
||||
var peerExports []bo.PeerExport
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
@@ -198,8 +217,12 @@ func ImportPeer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// failService, а не vo.Fail: импорт умеет завершиться ЧАСТИЧНО — партия
|
||||
// зафиксирована в базе целиком, но завершить старые сессии обновлённых
|
||||
// пиров не удалось. Полный отказ здесь означал бы для оператора «файл не
|
||||
// применился», хотя он применился весь.
|
||||
if err = service.UpsertPeerExport(peerExports); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
@@ -255,8 +278,12 @@ func KickPeer(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// failService, а не vo.Fail: отзыв доступа умеет завершиться ЧАСТИЧНО —
|
||||
// блокировка записана, активную сессию завершить не удалось. Через
|
||||
// vo.Fail этот результат уезжал бы панели неотличимо от полного отказа, и
|
||||
// оператор сделал бы неверный вывод о состоянии пира.
|
||||
if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
failService(err, c)
|
||||
return
|
||||
}
|
||||
vo.Success(nil, c)
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
|
||||
// первого обращения к слою данных, и это часть контракта — испорченный файл не
|
||||
// должен доходить до транзакции.
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
func postImportBytes(t *testing.T, fileName string, content []byte) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать форму: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte(content)); err != nil {
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("не удалось записать файл в форму: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func postImportFile(t *testing.T, fileName string, content string) apiResult {
|
||||
t.Helper()
|
||||
return postImportBytes(t, fileName, []byte(content))
|
||||
}
|
||||
|
||||
func TestImportPeerRejectsInvalidUTF8(t *testing.T) {
|
||||
for _, content := range [][]byte{
|
||||
{'[', '"', 0xFF, '"', ']'},
|
||||
{'[', '"', 0xC3, '"', ']'},
|
||||
{'[', '"', 0xED, 0xA0, 0x80, '"', ']'},
|
||||
} {
|
||||
result := postImportBytes(t, "peers.json", content)
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("повреждённый UTF-8 принят: %+v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||
// половина файла молча не применялась.
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Контракт формы пира: необязательный секрет и внятный отказ.
|
||||
//
|
||||
// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила
|
||||
// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих
|
||||
// слоёв и ни один из них по отдельности его не показывал: панель обещала
|
||||
// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на
|
||||
// поле-указателе отказывало раньше, чем управление доходило до сервиса.
|
||||
|
||||
func newPeerControllerDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||
}
|
||||
|
||||
// answerTrafficStats поднимает минимальный Traffic Stats API Hysteria.
|
||||
//
|
||||
// Нужен там, где тест доходит до service.Hysteria2Auth: проверка лимита
|
||||
// устройств теперь fail-closed, и подключение без ответа Traffic Stats API
|
||||
// отклоняется (см. AUTH-01/AUTH-02). Наблюдений этот ответчик не ведёт —
|
||||
// контракт `/kick` и `/online` доказывается в пакете service; здесь он лишь
|
||||
// делает вопрос «сколько устройств онлайн» отвечаемым.
|
||||
func answerTrafficStats(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/online" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", server.URL, err)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := "listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:" + port + "\"\n secret: \"test-secret\"\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
|
||||
}
|
||||
|
||||
previous := constant.Hysteria2ConfigPath
|
||||
constant.Hysteria2ConfigPath = path
|
||||
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||
|
||||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, "test-secret"); err != nil {
|
||||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// peerPayload — тело создания пира со всеми обязательными полями.
|
||||
// Тесты меняют в нём ровно то, что проверяют.
|
||||
func peerPayload(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"quotaBytes": -1,
|
||||
"expiresAt": 0,
|
||||
"maxDevices": 3,
|
||||
"disabled": 0,
|
||||
"remark": "",
|
||||
}
|
||||
}
|
||||
|
||||
func createPeer(t *testing.T, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
return postJSON(t, SavePeer, "/peers", body)
|
||||
}
|
||||
|
||||
// errorFor возвращает причину отказа по имени поля.
|
||||
func errorFor(t *testing.T, result apiResult, field string) (string, bool) {
|
||||
t.Helper()
|
||||
for _, item := range result.Errors {
|
||||
if item.Field == field {
|
||||
return item.Code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func storedPeer(t *testing.T, name string) entity.Peer {
|
||||
t.Helper()
|
||||
peer, err := dao.GetPeer("name = ?", name)
|
||||
if err != nil {
|
||||
t.Fatalf("пир %q не найден в базе: %v", name, err)
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем
|
||||
// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на
|
||||
// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора),
|
||||
// применялось к пустой строке и отказывало. Оператор видел «Invalid», а
|
||||
// генерация в CreatePeer была недостижима.
|
||||
func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
|
||||
cases := map[string]func(map[string]any){
|
||||
"поле отсутствует": func(body map[string]any) {},
|
||||
"пустая строка": func(body map[string]any) { body["secret"] = "" },
|
||||
"только пробелы": func(body map[string]any) { body["secret"] = " " },
|
||||
"перевод строки": func(body map[string]any) { body["secret"] = "\n" },
|
||||
"табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " },
|
||||
}
|
||||
|
||||
for label, mutate := range cases {
|
||||
t.Run(label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
answerTrafficStats(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
mutate(body)
|
||||
|
||||
result := createPeer(t, body)
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
|
||||
peer := storedPeer(t, "client-01")
|
||||
if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" {
|
||||
t.Fatal("секрет не сохранён")
|
||||
}
|
||||
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("сохранённый секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if len(secret) < 6 {
|
||||
t.Fatalf("сгенерирован слишком короткий секрет: %q", secret)
|
||||
}
|
||||
|
||||
// Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он
|
||||
// записан, ничего не значит, пока по нему не проходит проверка
|
||||
// доступа. Это же связывает digest и шифртекст между собой.
|
||||
id, authID, authErr := service.Hysteria2Auth(secret)
|
||||
if authErr != nil {
|
||||
t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr)
|
||||
}
|
||||
if id != *peer.Id || authID != *peer.AuthId {
|
||||
t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Два одинаковых запроса не должны давать одинаковый секрет: генератор
|
||||
// обязан быть случайным, а не производной от имени.
|
||||
func TestGeneratedPeerSecretsDiffer(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
secrets := make(map[string]struct{}, 5)
|
||||
for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} {
|
||||
if result := createPeer(t, peerPayload(name)); result.Type != "ok" {
|
||||
t.Fatalf("создание %q отклонено: %+v", name, result)
|
||||
}
|
||||
peer := storedPeer(t, name)
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет %q не расшифровывается: %v", name, err)
|
||||
}
|
||||
if _, seen := secrets[secret]; seen {
|
||||
t.Fatalf("сгенерированный секрет повторился: %q", secret)
|
||||
}
|
||||
secrets[secret] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы ручного секрета — ровно те, что обещает подсказка под полем.
|
||||
func TestCreatePeerSecretLengthBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
secret string
|
||||
accepted bool
|
||||
expectCode string
|
||||
}{
|
||||
{"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength},
|
||||
{"6 символов", strings.Repeat("a", 6), true, ""},
|
||||
{"128 символов", strings.Repeat("a", 128), true, ""},
|
||||
{"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["secret"] = tc.secret
|
||||
result := createPeer(t, body)
|
||||
|
||||
if tc.accepted {
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if stored != tc.secret {
|
||||
t.Fatalf("сохранён не тот секрет, который передали")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("секрет длиной %d принят", len(tc.secret))
|
||||
}
|
||||
code, ok := errorFor(t, result, "secret")
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле secret: %+v", result.Errors)
|
||||
}
|
||||
if code != tc.expectCode {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово
|
||||
// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и
|
||||
// вынуждена была бы разбирать текст, чтобы попытаться.
|
||||
func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
body func() map[string]any
|
||||
field string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
label: "имя не передано",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
delete(body, "name")
|
||||
return body
|
||||
},
|
||||
field: "name",
|
||||
code: constant.ErrCodeRequired,
|
||||
},
|
||||
{
|
||||
label: "имя короче допустимого",
|
||||
body: func() map[string]any { return peerPayload("pc1") },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "имя длиннее допустимого",
|
||||
body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "лимит устройств меньше единицы",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["maxDevices"] = 0
|
||||
return body
|
||||
},
|
||||
field: "maxDevices",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "disabled вне множества значений",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["disabled"] = 7
|
||||
return body
|
||||
},
|
||||
field: "disabled",
|
||||
code: constant.ErrCodeOneOf,
|
||||
},
|
||||
{
|
||||
label: "квота меньше минимума",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["quotaBytes"] = -2
|
||||
return body
|
||||
},
|
||||
field: "quotaBytes",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "комментарий длиннее допустимого",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = strings.Repeat("я", 65)
|
||||
return body
|
||||
},
|
||||
field: "remark",
|
||||
code: constant.ErrCodeMaxLength,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, tc.body())
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("некорректный ввод принят: %+v", result)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError)
|
||||
}
|
||||
code, ok := errorFor(t, result, tc.field)
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors)
|
||||
}
|
||||
if code != tc.code {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.code)
|
||||
}
|
||||
// Сообщение остаётся человекочитаемым для клиента без панели, но
|
||||
// панель им не пользуется: у неё есть код.
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
t.Fatal("отказ без человекочитаемого сообщения")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой
|
||||
// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`.
|
||||
// Имя `peer/name` создавалось через панель и отклонялось импортом того же
|
||||
// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет.
|
||||
func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"peer/name",
|
||||
"peer:name",
|
||||
"peer;name",
|
||||
"peer,name",
|
||||
"peer.name",
|
||||
"peer<name",
|
||||
"peer name",
|
||||
"пир-01",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(name))
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("имя %q принято", name)
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerName {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerName)
|
||||
}
|
||||
// Обе двери в таблицу пиров обязаны требовать одного и того же.
|
||||
if service.IsValidPeerName(name) {
|
||||
t.Fatalf("импорт принимает имя %q, которое отклоняет панель", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsTakenName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("первое создание отклонено: %+v", result)
|
||||
}
|
||||
|
||||
result := createPeer(t, peerPayload("client-01"))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("повторное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameTaken {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameTaken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsReservedName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(service.ReservedBootstrapPeerName))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("зарезервированное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameReserved {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameReserved)
|
||||
}
|
||||
}
|
||||
|
||||
// Тело, которое вообще не разобралось, — это не нарушение правила поля.
|
||||
// Панели важно различать: в первом случае подсвечивать нечего.
|
||||
func TestCreatePeerReportsUnparsableBody(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.POST("/peers", SavePeer)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/peers", strings.NewReader("{не json"))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
if result.Type != "no" {
|
||||
t.Fatal("неразбираемое тело принято")
|
||||
}
|
||||
if len(result.Errors) != 1 || result.Errors[0].Code != constant.ErrCodeBodyInvalid {
|
||||
t.Fatalf("неожиданное описание отказа: %+v", result.Errors)
|
||||
}
|
||||
if result.Errors[0].Field != "" {
|
||||
t.Fatalf("отказ разбора привязан к полю %q", result.Errors[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
// patchPeer выполняет PATCH /peers/:id так же, как это делает панель.
|
||||
func patchPeer(t *testing.T, id int64, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.PATCH("/peers/:id", UpdatePeer)
|
||||
|
||||
target := "/peers/" + strconv.FormatInt(id, 10)
|
||||
request := httptest.NewRequest(http.MethodPatch, target, strings.NewReader(string(payload)))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// При изменении пустой секрет означает «не менять», и это то же самое
|
||||
// состояние, что и отсутствие поля. Панель отправляет `secret: ""` всякий раз,
|
||||
// когда оператор открыл форму и не трогал поле секрета.
|
||||
func TestUpdatePeerKeepsSecretWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
before := storedPeer(t, "client-01")
|
||||
|
||||
for _, blank := range []string{"", " "} {
|
||||
result := patchPeer(t, *before.Id, map[string]any{
|
||||
"name": "client-01",
|
||||
"secret": blank,
|
||||
"remark": "рабочее устройство",
|
||||
})
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("изменение с пустым секретом %q отклонено: %+v", blank, result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if *after.SecretDigest != *before.SecretDigest {
|
||||
t.Fatal("секрет пира изменился, хотя поле оставили пустым")
|
||||
}
|
||||
if after.Remark == nil || *after.Remark != "рабочее устройство" {
|
||||
t.Fatal("остальные поля формы не применились")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой комментарий обязан ОЧИЩАТЬ комментарий, а не означать «не менять»:
|
||||
// иначе оператор не может убрать однажды сделанную пометку. Это граница, по
|
||||
// которой нормализация проходит для каждого поля отдельно.
|
||||
func TestUpdatePeerClearsRemarkWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = "временная пометка"
|
||||
if result := createPeer(t, body); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
|
||||
if result := patchPeer(t, *peer.Id, map[string]any{"remark": ""}); result.Type != "ok" {
|
||||
t.Fatalf("очистка комментария отклонена: %+v", result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if after.Remark != nil && *after.Remark != "" {
|
||||
t.Fatalf("комментарий не очищен: %q", *after.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия, найденная вместе с UX-02 и в отчёте не значившаяся: `el-input`
|
||||
// с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`,
|
||||
// и та же ловушка `omitempty` на указателе отказывала поиску пиров с
|
||||
// «invalid» — то есть список пиров ломался в один клик по крестику.
|
||||
func TestPagePeerAcceptsClearedFilters(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/peers", PagePeer)
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("pageNum", "1")
|
||||
query.Set("pageSize", "10")
|
||||
query.Set("name", "")
|
||||
query.Set("remark", "")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/peers?"+query.Encode(), nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("очищенный фильтр отклонён: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,261 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
var validate *validator.Validate
|
||||
|
||||
func init() {
|
||||
validate = validator.New()
|
||||
_ = validate.RegisterValidation("validateStr", validateStr)
|
||||
|
||||
// Имя поля в отказе — это имя из JSON, а не из структуры Go. Панель знает
|
||||
// поля формы под теми именами, под которыми их отправляет; `Secret` вместо
|
||||
// `secret` заставил бы её переводить одно в другое ещё одним словарём.
|
||||
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
|
||||
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "" || name == "-" {
|
||||
return field.Name
|
||||
}
|
||||
return name
|
||||
})
|
||||
|
||||
mustRegister("peerName", validatePeerName)
|
||||
mustRegister("credentialStr", validateCredentialStr)
|
||||
mustRegister("adminPassword", validateAdminPassword)
|
||||
}
|
||||
|
||||
func validateStr(f validator.FieldLevel) bool {
|
||||
field := f.Field().String()
|
||||
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов
|
||||
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
|
||||
compile := regexp.MustCompile(reg)
|
||||
return field == "" || compile.MatchString(field)
|
||||
func mustRegister(tag string, fn validator.Func) {
|
||||
if err := validate.RegisterValidation(tag, fn); err != nil {
|
||||
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
|
||||
}
|
||||
}
|
||||
|
||||
// validatePeerName — единственное правило имени пира.
|
||||
//
|
||||
// Набор символов и длина берутся из service: имя пира проверяется на двух
|
||||
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
|
||||
// независимые копии правила уже расходились. Копия в слое контроллеров
|
||||
// выглядела так:
|
||||
//
|
||||
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
|
||||
//
|
||||
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
|
||||
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
|
||||
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
|
||||
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
|
||||
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
|
||||
func validatePeerName(f validator.FieldLevel) bool {
|
||||
return service.IsValidPeerName(f.Field().String())
|
||||
}
|
||||
|
||||
// validateCredentialStr — единственное правило имени администратора.
|
||||
//
|
||||
// Набор символов и длина берутся из service по той же причине, что и у имени
|
||||
// пира: собственная копия правила в слое контроллеров уже расходилась с
|
||||
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
|
||||
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
|
||||
// третье правило.
|
||||
//
|
||||
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
|
||||
// была ловушка: у поля-указателя `required` считает непустым сам факт
|
||||
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
|
||||
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
|
||||
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||
// у него на самом деле.
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
return credential.IsValidAdminUsername(f.Field().String())
|
||||
}
|
||||
|
||||
// validateAdminPassword — единственное правило пароля администратора.
|
||||
//
|
||||
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
|
||||
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
|
||||
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
|
||||
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
|
||||
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
|
||||
// системная ошибка на штатной смене пароля.
|
||||
//
|
||||
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
|
||||
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
|
||||
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
|
||||
func validateAdminPassword(f validator.FieldLevel) bool {
|
||||
return credential.IsValidAdminPassword(f.Field().String())
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
// правила.
|
||||
//
|
||||
// Отказ описывается ПОЛЯМИ, а не одним словом. Раньше и ошибка разбора тела, и
|
||||
// нарушение любого правила любого поля превращались в одну строку `invalid`:
|
||||
// оператор, оставивший секрет пустым, видел «Invalid» и не имел ни одного
|
||||
// способа узнать, что именно не так, — а не так было ровно то, что панель ему
|
||||
// же и предлагала сделать.
|
||||
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
|
||||
var bindErr error
|
||||
if c.Request.Method == http.MethodGet {
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
bindErr = c.ShouldBindQuery(&field)
|
||||
} else if c.Request.Method == http.MethodPost ||
|
||||
c.Request.Method == http.MethodPut ||
|
||||
c.Request.Method == http.MethodPatch ||
|
||||
c.Request.Method == http.MethodDelete {
|
||||
bindErr = c.ShouldBindJSON(&field)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
bindErr = strictBindJSON(c, &field)
|
||||
}
|
||||
if bindErr != nil {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return field, fmt.Errorf(constant.InvalidError)
|
||||
vo.FailValidation(
|
||||
"запрос не разобран: проверьте формат и типы полей",
|
||||
[]vo.FieldError{{
|
||||
Code: constant.ErrCodeBodyInvalid,
|
||||
Message: bindErr.Error(),
|
||||
}},
|
||||
c,
|
||||
)
|
||||
return field, errors.New(constant.ErrCodeBodyInvalid)
|
||||
}
|
||||
|
||||
// Нормализация идёт между разбором и проверкой: правила обязаны видеть уже
|
||||
// каноничный вход, иначе «не задано» и «задано пустым» остаются разными
|
||||
// состояниями для валидатора и одинаковыми для человека.
|
||||
if normalizable, ok := any(&field).(dto.Normalizable); ok {
|
||||
normalizable.Normalize()
|
||||
}
|
||||
|
||||
if err := validate.Struct(&field); err != nil {
|
||||
vo.Fail(constant.InvalidError, c)
|
||||
return field, fmt.Errorf(constant.InvalidError)
|
||||
vo.FailValidation(
|
||||
"проверка данных не пройдена",
|
||||
describeValidationErrors(err),
|
||||
c,
|
||||
)
|
||||
return field, errors.New(constant.ErrCodeValidationFailed)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// describeValidationErrors переводит отказ валидатора в список причин.
|
||||
func describeValidationErrors(err error) []vo.FieldError {
|
||||
var validationErrors validator.ValidationErrors
|
||||
if !errors.As(err, &validationErrors) {
|
||||
// InvalidValidationError означает ошибку программиста (в проверку
|
||||
// передали не структуру), а не плохой вход оператора. Скрывать её за
|
||||
// сообщением о поле нельзя: она никогда не чинится правкой формы.
|
||||
return []vo.FieldError{{
|
||||
Code: constant.ErrCodeValidationFailed,
|
||||
Message: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
out := make([]vo.FieldError, 0, len(validationErrors))
|
||||
for _, fieldErr := range validationErrors {
|
||||
out = append(out, describeFieldError(fieldErr))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isTextField сообщает, что `min`/`max` на этом поле ограничивают ДЛИНУ, а не
|
||||
// величину. Указатели валидатор к этому моменту уже разыменовал.
|
||||
func isTextField(fieldErr validator.FieldError) bool {
|
||||
return fieldErr.Kind() == reflect.String
|
||||
}
|
||||
|
||||
func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
field := fieldErr.Field()
|
||||
param := fieldErr.Param()
|
||||
|
||||
described := vo.FieldError{Field: field}
|
||||
switch fieldErr.Tag() {
|
||||
case "required":
|
||||
described.Code = constant.ErrCodeRequired
|
||||
described.Message = fmt.Sprintf("поле %q обязательно", field)
|
||||
case "min":
|
||||
if isTextField(fieldErr) {
|
||||
described.Code = constant.ErrCodeMinLength
|
||||
described.Params = map[string]string{"min": param}
|
||||
described.Message = fmt.Sprintf("поле %q короче %s символов", field, param)
|
||||
break
|
||||
}
|
||||
described.Code = constant.ErrCodeMin
|
||||
described.Params = map[string]string{"min": param}
|
||||
described.Message = fmt.Sprintf("поле %q меньше допустимого минимума %s", field, param)
|
||||
case "max":
|
||||
if isTextField(fieldErr) {
|
||||
described.Code = constant.ErrCodeMaxLength
|
||||
described.Params = map[string]string{"max": param}
|
||||
described.Message = fmt.Sprintf("поле %q длиннее %s символов", field, param)
|
||||
break
|
||||
}
|
||||
described.Code = constant.ErrCodeMax
|
||||
described.Params = map[string]string{"max": param}
|
||||
described.Message = fmt.Sprintf("поле %q больше допустимого максимума %s", field, param)
|
||||
case "len":
|
||||
described.Code = constant.ErrCodeLen
|
||||
described.Params = map[string]string{"len": param}
|
||||
described.Message = fmt.Sprintf("поле %q должно иметь длину %s", field, param)
|
||||
case "oneof":
|
||||
described.Code = constant.ErrCodeOneOf
|
||||
described.Params = map[string]string{"values": param}
|
||||
described.Message = fmt.Sprintf("поле %q принимает одно из значений: %s", field, param)
|
||||
case "gt":
|
||||
described.Code = constant.ErrCodeGreaterThan
|
||||
described.Params = map[string]string{"gt": param}
|
||||
described.Message = fmt.Sprintf("поле %q должно быть больше %s", field, param)
|
||||
case "peerName":
|
||||
described.Code = constant.ErrCodePeerName
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", service.PeerNameMinLength),
|
||||
"max": fmt.Sprintf("%d", service.PeerNameMaxLength),
|
||||
"charset": service.PeerNameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"имя пира: от %d до %d символов из набора %s",
|
||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||
)
|
||||
case "credentialStr":
|
||||
// Причина называет набор и границы ровно так же, как это делает
|
||||
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
|
||||
// вопрос оператора: набор символов логина ему негде посмотреть, а
|
||||
// отказ по длине этой фразой описывался как отказ по символам.
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
|
||||
"charset": credential.AdminUsernameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов из набора %s",
|
||||
field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
|
||||
)
|
||||
case "adminPassword":
|
||||
// Причина называет ОБЕ границы, потому что их две и они в разных
|
||||
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
|
||||
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
|
||||
// такую причину, сокращал бы пароль, который отвергнут не за это.
|
||||
described.Code = constant.ErrCodeAdminPassword
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
|
||||
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
field,
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
|
||||
}
|
||||
return described
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
|
||||
//
|
||||
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
|
||||
// потому не увидели бы расхождения между копией и правилом — а именно так этот
|
||||
// класс дефектов и появлялся: набор символов логина существовал в четырёх
|
||||
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
|
||||
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
|
||||
// одинаково на одних и тех же значениях.
|
||||
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"hy2xsadmin",
|
||||
"admin.ops",
|
||||
"admin+1",
|
||||
"admi",
|
||||
"админ12",
|
||||
"admin пробел",
|
||||
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
|
||||
} {
|
||||
expected := credential.IsValidAdminUsername(candidate)
|
||||
if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
|
||||
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же вопрос про пароль, и он важнее.
|
||||
//
|
||||
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
|
||||
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
|
||||
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
|
||||
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
|
||||
// закрепляет, что правило теперь одно и что оно то же самое, которым
|
||||
// пользуются bootstrap-установка и `reset-admin`.
|
||||
func TestAdminPasswordTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"bootstrap-password",
|
||||
"abcde ",
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength+1),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde\n",
|
||||
} {
|
||||
expected := credential.IsValidAdminPassword(candidate)
|
||||
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
|
||||
t.Errorf(
|
||||
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
|
||||
len([]rune(candidate)), len(candidate), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
|
||||
// одну таблицу и не имеют права требовать разного.
|
||||
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||||
accepted := []string{
|
||||
"client-01",
|
||||
"alpha1",
|
||||
"bootstrap-admin-peer",
|
||||
strings.Repeat("a", 6),
|
||||
strings.Repeat("a", 32),
|
||||
}
|
||||
for _, name := range accepted {
|
||||
if !service.IsValidPeerName(name) {
|
||||
t.Errorf("имя %q отклонено, ожидался приём", name)
|
||||
}
|
||||
}
|
||||
|
||||
rejected := []string{
|
||||
"",
|
||||
" ",
|
||||
"pc1",
|
||||
strings.Repeat("a", 33),
|
||||
"peer name",
|
||||
"peer\nname",
|
||||
"peer/name",
|
||||
"peer:name",
|
||||
"peer.name",
|
||||
"пир-01",
|
||||
}
|
||||
for _, name := range rejected {
|
||||
if service.IsValidPeerName(name) {
|
||||
t.Errorf("имя %q принято, ожидался отказ", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package credential объявляет контракт учётных данных администратора — ОДИН
|
||||
// на весь продукт.
|
||||
//
|
||||
// Почему это отдельный пакет, а не файл в service. Контракт нужен четырём
|
||||
// слоям сразу, и два из них лежат НИЖЕ service по графу импортов:
|
||||
//
|
||||
// controller -> service -> util (хеширование пароля)
|
||||
// dao -> util (создание первой учётной записи)
|
||||
// cmd -> dao, util (reset-admin)
|
||||
//
|
||||
// Пока контракт жил в service, `util.HashPassword` не мог его позвать — импорт
|
||||
// был бы циклическим, — и завёл собственную проверку `len(TrimSpace(p)) < 6`.
|
||||
// Эта третья копия правила разошлась с остальными ровно тем способом, ради
|
||||
// предотвращения которого контракт и объявлен: LoginDto считал `"abcde "`
|
||||
// корректным паролем, а HashPassword отказывался его хешировать. Пакет-лист
|
||||
// ниже всех потребителей — единственная запись, при которой у правила
|
||||
// физически не может появиться второго экземпляра.
|
||||
package credential
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Контракт учётных данных администратора.
|
||||
//
|
||||
// Про логин и пароль администратора одновременно знали четыре места: регексп в
|
||||
// слое контроллеров, границы длины в тегах LoginDto, правило формы входа в
|
||||
// панели и правило формы смены пароля в ней же. Ни одно из них не ссылалось на
|
||||
// остальные, и все четыре успели разойтись:
|
||||
//
|
||||
// - тег `validateStr` на Username пережил переименование правила и остался
|
||||
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
|
||||
// а gin.Recovery превращал панику в HTTP 500;
|
||||
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
|
||||
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
|
||||
// форма входа отказывалась отправлять: панель запирала оператора снаружи
|
||||
// после операции, которую сама же и предложила;
|
||||
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
|
||||
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
|
||||
// завершалась успешно, а войти в панель было нельзя.
|
||||
//
|
||||
// Общее у всех трёх — контракт существовал только как совпадение литералов,
|
||||
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
|
||||
// IsValidAdminUsername и IsValidAdminPassword, а расхождение с панелью и
|
||||
// оркестратором ловится тестами, читающими этот же файл.
|
||||
const (
|
||||
AdminUsernameMinLength = 6
|
||||
AdminUsernameMaxLength = 32
|
||||
|
||||
// Границы пароля в СИМВОЛАХ Unicode (code points).
|
||||
AdminPasswordMinLength = 6
|
||||
AdminPasswordMaxLength = 64
|
||||
|
||||
// AdminPasswordMaxBytes — предел, установленный bcrypt.
|
||||
//
|
||||
// Он объявлен ОТДЕЛЬНО от AdminPasswordMaxLength, потому что это ДРУГАЯ
|
||||
// единица измерения, и подменять одну другой нельзя.
|
||||
//
|
||||
// Здесь стоял ровно этот дефект. Верхняя граница в 64 символа была выбрана
|
||||
// «заведомо ниже 72 байт», и обоснование выглядело убедительно, пока пароль
|
||||
// оставался ASCII. Но `golang.org/x/crypto/bcrypt` считает БАЙТЫ:
|
||||
//
|
||||
// 64 x "a" = 64 байта -> GenerateFromPassword принимает
|
||||
// 64 x "я" = 128 байт -> ErrPasswordTooLong
|
||||
// 19 x "😀" = 76 байт -> ErrPasswordTooLong
|
||||
//
|
||||
// причём отказ приходил НЕ на форме, а из хеширования: смена пароля на 64
|
||||
// кириллические буквы проходила все проверки продукта и заканчивалась
|
||||
// системной ошибкой на штатной операции. Комментарий в прежней редакции
|
||||
// вдобавок утверждал, что bcrypt «молча отбрасывает остаток» — так вело
|
||||
// себя издание пакета до v0.28; действующее (v0.55.0, bcrypt.go:96)
|
||||
// отвечает ошибкой:
|
||||
//
|
||||
// if len(password) > 72 { return nil, ErrPasswordTooLong }
|
||||
AdminPasswordMaxBytes = 72
|
||||
)
|
||||
|
||||
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
|
||||
//
|
||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
|
||||
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
|
||||
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
|
||||
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
|
||||
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
|
||||
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
|
||||
|
||||
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
|
||||
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
|
||||
// собственную копию того же множества.
|
||||
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
|
||||
|
||||
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
|
||||
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
|
||||
var adminUsernamePattern = regexp.MustCompile(
|
||||
"^[" + adminUsernameCharacterClass + "]{" +
|
||||
strconv.Itoa(AdminUsernameMinLength) + "," +
|
||||
strconv.Itoa(AdminUsernameMaxLength) + "}$",
|
||||
)
|
||||
|
||||
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
|
||||
//
|
||||
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
|
||||
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
|
||||
// значение приходит из файла окружения, а не из DTO.
|
||||
func IsValidAdminUsername(username string) bool {
|
||||
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
// Noncharacters и суррогаты — то, что НЕ ПРИМЕТ systemd.
|
||||
//
|
||||
// `EnvironmentFile=` разбирается systemd, и перед тем как принять пару, он
|
||||
// прогоняет ключ и значение через `utf8_is_valid` (src/basic/env-file.c,
|
||||
// `check_utf8ness_and_warn`). Отказ там — это `-EINVAL`, то есть НЕ загруженный
|
||||
// environment file, то есть юнит, который не стартует; предупреждением это не
|
||||
// является.
|
||||
//
|
||||
// `utf8_is_valid` отвергает встроенный NUL и всё, что не является Unicode
|
||||
// scalar value, а `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
|
||||
//
|
||||
// Пока контракт этого не знал, пароль вроде `abcde` — шесть символов,
|
||||
// восемь байт, ни одного управляющего — проходил ВСЕ проверки продукта,
|
||||
// записывался в /etc/hy2xs/hy2xs.env, и админка после этого не стартовала. Тот
|
||||
// же класс дефекта, ради уничтожения которого контракт и существует, только на
|
||||
// один слой ниже.
|
||||
//
|
||||
// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
|
||||
// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
|
||||
// документированному контракту, а не этому расхождению реализации.
|
||||
func isUnicodeNoncharacter(r rune) bool {
|
||||
if r >= 0xFDD0 && r <= 0xFDEF {
|
||||
return true
|
||||
}
|
||||
return r&0xFFFE == 0xFFFE
|
||||
}
|
||||
|
||||
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
|
||||
//
|
||||
// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
|
||||
// (границы длины, запрет управляющих символов) живут отдельно — иначе
|
||||
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
|
||||
func IsEnvTransportableText(value string) bool {
|
||||
// Невалидный UTF-8 сюда попадает не только из файла: Go-строка — это
|
||||
// произвольные байты, и `[]byte` из внешнего источника может оказаться
|
||||
// чем угодно. Проверка идёт ПЕРВОЙ, потому что `range` по невалидной строке
|
||||
// молча отдаёт U+FFFD и скрыл бы причину.
|
||||
if !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
|
||||
// отдельной веткой (`embedded NUL`).
|
||||
if r == 0 || r == 0xFEFF {
|
||||
return false
|
||||
}
|
||||
if isUnicodeNoncharacter(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsValidAdminPassword — единственное правило пароля администратора.
|
||||
//
|
||||
// Правило состоит из четырёх частей и НИ ОДНА из них не является набором
|
||||
// символов:
|
||||
//
|
||||
// домен systemd — значение обязано быть загружаемым из EnvironmentFile
|
||||
// (IsEnvTransportableText);
|
||||
// 6-64 code points — граница, которую видит оператор;
|
||||
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
|
||||
// Cc — продуктовая политика, см. hasForbiddenRune.
|
||||
//
|
||||
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
|
||||
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
|
||||
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
|
||||
// латиницы и для кириллицы.
|
||||
//
|
||||
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
|
||||
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
|
||||
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
|
||||
//
|
||||
// Набор символов НЕ ограничивается ничем сверх перечисленного. Пароль назначает
|
||||
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
|
||||
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
|
||||
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
|
||||
// пароль, который сервер принял бы.
|
||||
//
|
||||
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
|
||||
// нормализации DTO, ни при чтении bootstrap-окружения.
|
||||
func IsValidAdminPassword(password string) bool {
|
||||
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до
|
||||
// админки вовсе — systemd откажется загружать файл окружения, и юнит не
|
||||
// стартует. Это отказ более грубого рода, чем нарушение границ длины.
|
||||
if !IsEnvTransportableText(password) {
|
||||
return false
|
||||
}
|
||||
runes := utf8.RuneCountInString(password)
|
||||
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
|
||||
return false
|
||||
}
|
||||
if len(password) > AdminPasswordMaxBytes {
|
||||
return false
|
||||
}
|
||||
return !hasForbiddenRune(password)
|
||||
}
|
||||
|
||||
// hasForbiddenRune — продуктовая политика HY2XS поверх домена systemd.
|
||||
//
|
||||
// Запрещены УПРАВЛЯЮЩИЕ символы Unicode целиком, то есть категория Cc:
|
||||
//
|
||||
// U+0000..U+001F C0
|
||||
// U+007F DEL
|
||||
// U+0080..U+009F C1
|
||||
//
|
||||
// Раньше здесь стояли только C0 и DEL, а комментарий обещал «без управляющих
|
||||
// символов» — то есть документация была шире кода ровно на C1. Проверяется
|
||||
// теперь то, что обещано.
|
||||
//
|
||||
// U+FEFF здесь уже не проверяется: он принадлежит документированному домену
|
||||
// EnvironmentFile и отвергается IsEnvTransportableText.
|
||||
func hasForbiddenRune(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
// оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !IsValidAdminUsername(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"", // не задано
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
} {
|
||||
if IsValidAdminUsername(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||||
// обязан описывать тот же класс, что и регексп.
|
||||
//
|
||||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||||
// оператор читает набор, которого правило не принимает.
|
||||
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
||||
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) {
|
||||
t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength)
|
||||
}
|
||||
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength)
|
||||
}
|
||||
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength)
|
||||
}
|
||||
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) {
|
||||
t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength)
|
||||
}
|
||||
|
||||
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
||||
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
||||
punctuation := strings.Fields(AdminUsernameCharset)
|
||||
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
||||
if !IsValidAdminUsername("admin" + symbol) {
|
||||
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные
|
||||
// границы.
|
||||
//
|
||||
// Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
|
||||
// байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок
|
||||
// требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым
|
||||
// фиксировал как ожидаемое ровно то значение, на котором продукт ломался:
|
||||
// оркестратор его пропускал, DTO пропускало, а bcrypt отвечал
|
||||
// ErrPasswordTooLong уже при создании учётной записи.
|
||||
func TestAdminPasswordBoundaries(t *testing.T) {
|
||||
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» —
|
||||
// четырёхбайтовым, случаи ниже проверяли бы не то, что написано.
|
||||
if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
||||
}
|
||||
if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
password string
|
||||
accepted bool
|
||||
}{
|
||||
{"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true},
|
||||
{"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false},
|
||||
{"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true},
|
||||
{"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false},
|
||||
|
||||
// Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов.
|
||||
{"36 x «я» = 72 байта", strings.Repeat("я", 36), true},
|
||||
{"37 x «я» = 74 байта", strings.Repeat("я", 37), false},
|
||||
{"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true},
|
||||
{"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false},
|
||||
|
||||
// Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА.
|
||||
{"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false},
|
||||
|
||||
// Пробелы по краям — часть пароля, а не мусор ввода.
|
||||
{"пробел на конце", "abcde ", true},
|
||||
{"пробел в начале", " abcde", true},
|
||||
{"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true},
|
||||
|
||||
// Набор символов не ограничен ничем, кроме управляющих.
|
||||
{"кириллица", "пароль-администратора", true},
|
||||
{"знаки препинания", `p@$$w0rd"\'#;`, true},
|
||||
{"перевод строки", "abcde\n", false},
|
||||
{"возврат каретки", "abcde\r", false},
|
||||
{"табуляция", "abcde\t", false},
|
||||
{"нулевой байт", "abcde\x00", false},
|
||||
{"DEL", "abcde\x7f", false},
|
||||
|
||||
// C1: раньше документация обещала «без управляющих символов», а код
|
||||
// проверял только C0 и DEL — то есть обещание было шире проверки.
|
||||
{"C1 NEL", "abcde" + string(rune(0x85)), false},
|
||||
{"C1 верхняя граница", "abcde" + string(rune(0x9F)), false},
|
||||
|
||||
// Домен systemd. Каждое из этих значений — шесть символов, ни одного
|
||||
// управляющего, укладывается в 72 байта, — и при этом делает
|
||||
// /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn`
|
||||
// отвечает -EINVAL, и юнит hy2xs-admin не стартует.
|
||||
{"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false},
|
||||
{"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false},
|
||||
{"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false},
|
||||
{"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false},
|
||||
{"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false},
|
||||
{"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false},
|
||||
{"невалидный UTF-8", "abcde\xff", false},
|
||||
|
||||
// Соседи noncharacters приниматься обязаны: правило описывает ровно
|
||||
// множество systemd, а не «всё подозрительное рядом».
|
||||
{"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true},
|
||||
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
|
||||
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
|
||||
|
||||
// U+FEFF отвергается документированным транспортным доменом systemd.
|
||||
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := IsValidAdminPassword(testCase.password); got != testCase.accepted {
|
||||
t.Errorf(
|
||||
"IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)",
|
||||
got, testCase.accepted,
|
||||
utf8.RuneCountInString(testCase.password), len(testCase.password),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Предел в байтах не выводится из предела в символах, и обратное неверно тоже.
|
||||
//
|
||||
// Проверка защищает от «упрощения», при котором одну из двух констант объявят
|
||||
// производной от другой: у 64 символов длина от 64 до 256 байт.
|
||||
func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
|
||||
if AdminPasswordMaxBytes != 72 {
|
||||
t.Fatalf(
|
||||
"предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72",
|
||||
AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// Пароль, укладывающийся в символы и не укладывающийся в байты, обязан
|
||||
// существовать — иначе байтовая граница недостижима и ничего не проверяет.
|
||||
long := strings.Repeat("я", AdminPasswordMaxLength)
|
||||
if utf8.RuneCountInString(long) > AdminPasswordMaxLength {
|
||||
t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница")
|
||||
}
|
||||
if IsValidAdminPassword(long) {
|
||||
t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется")
|
||||
}
|
||||
}
|
||||
|
||||
// Домен транспорта повторяет публично документированное множество systemd.
|
||||
//
|
||||
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
|
||||
// значение проходит все двери продукта вопреки документации EnvironmentFile.
|
||||
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
|
||||
// noncharacters.
|
||||
//
|
||||
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
|
||||
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
|
||||
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
|
||||
rejected := []rune{
|
||||
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
|
||||
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
|
||||
0xFFFE, 0xFFFF,
|
||||
0x1FFFE, 0x1FFFF,
|
||||
0x10FFFE, 0x10FFFF,
|
||||
}
|
||||
for _, r := range rejected {
|
||||
if IsEnvTransportableText(string(r)) {
|
||||
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
|
||||
}
|
||||
}
|
||||
|
||||
accepted := []rune{
|
||||
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
|
||||
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
|
||||
0xFFFD, // replacement character — обычный символ
|
||||
0x10FFFD, // последний scalar value, не noncharacter
|
||||
0x1F600, // 😀
|
||||
}
|
||||
for _, r := range accepted {
|
||||
if !IsEnvTransportableText(string(r)) {
|
||||
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Невалидный UTF-8 — не «строка со странными символами», а байты, которые
|
||||
// systemd отвергает первой же проверкой.
|
||||
for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} {
|
||||
if IsEnvTransportableText(invalid) {
|
||||
t.Errorf("невалидный UTF-8 %q принят", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно.
|
||||
//
|
||||
// Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что
|
||||
// одно из двух правил отсутствует.
|
||||
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
|
||||
// Управляющие символы формат несёт — их запрещает HY2XS.
|
||||
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
|
||||
password := "abcde" + string(r)
|
||||
if !IsEnvTransportableText(password) && r != 0x00 {
|
||||
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
|
||||
}
|
||||
if IsValidAdminPassword(password) {
|
||||
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
|
||||
}
|
||||
}
|
||||
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
|
||||
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится нигде, включая сам контракт.
|
||||
//
|
||||
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
|
||||
// значение `"abcde "` считалось корректным всеми дверями продукта и не могло
|
||||
// быть захешировано.
|
||||
func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) {
|
||||
// Шесть символов, из которых последний — пробел. По длине без пробела это
|
||||
// пять символов, то есть прежняя проверка отказала бы.
|
||||
const password = "abcde "
|
||||
if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength {
|
||||
t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то")
|
||||
}
|
||||
if !IsValidAdminPassword(password) {
|
||||
t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля.
|
||||
//
|
||||
// Здесь было два расхождения, и оба ломали установку молча.
|
||||
//
|
||||
// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт
|
||||
// объявляет пробел по краям частью пароля и не снимает его ни на форме входа,
|
||||
// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась
|
||||
// с паролем, отличным от того, который оператор записал в hy2xs.env и которым
|
||||
// он потом пытался войти.
|
||||
//
|
||||
// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль
|
||||
// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт
|
||||
// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по
|
||||
// которому нельзя понять ни что чинить, ни где.
|
||||
|
||||
func startAdminBootstrap(t *testing.T, password string) error {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin")
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||
|
||||
err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db"))
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
return err
|
||||
}
|
||||
|
||||
// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом.
|
||||
func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) {
|
||||
const password = "bootstrap-admin-password "
|
||||
|
||||
if err := startAdminBootstrap(t, password); err != nil {
|
||||
t.Fatalf("запуск сервиса не удался: %v", err)
|
||||
}
|
||||
|
||||
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||
}
|
||||
if admin.PasswordHash == nil {
|
||||
t.Fatal("у созданной учётной записи нет хеша пароля")
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||
t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи")
|
||||
}
|
||||
if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) {
|
||||
t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой
|
||||
// bcrypt, и учётной записи после этого не остаётся.
|
||||
func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) {
|
||||
// 64 символа — в границе; 128 байт — за пределом bcrypt.
|
||||
password := strings.Repeat("я", credential.AdminPasswordMaxLength)
|
||||
if credential.IsValidAdminPassword(password) {
|
||||
t.Fatal("подготовка: контракт принимает значение, проверяется не то")
|
||||
}
|
||||
|
||||
err := startAdminBootstrap(t, password)
|
||||
if err == nil {
|
||||
t.Fatal("старт с непригодным паролем завершился успешно")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") {
|
||||
t.Errorf("отказ не называет переменную, которую надо чинить: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hy2xs.env") {
|
||||
t.Errorf("отказ не называет файл, в котором чинится причина: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с
|
||||
// ним обязана проходить целиком.
|
||||
func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
|
||||
password := strings.Repeat("я", 36)
|
||||
if len(password) != credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
if err := startAdminBootstrap(t, password); err != nil {
|
||||
t.Fatalf("установка с паролем в 72 байта не удалась: %v", err)
|
||||
}
|
||||
|
||||
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||
}
|
||||
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||
t.Error("пароль в 72 байта не проходит проверку собственным хешем")
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package dao
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/util"
|
||||
@@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
||||
// Пароль читается КАК ЕСТЬ.
|
||||
//
|
||||
// Здесь стоял strings.TrimSpace, и он противоречил контракту продукта:
|
||||
// пробел по краям объявлен частью пароля и не снимается ни на форме входа,
|
||||
// ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь
|
||||
// гарантированно создавал учётную запись НЕ с тем паролем, который оператор
|
||||
// записал в hy2xs.env, а войти в неё он пытался бы исходным значением.
|
||||
adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")
|
||||
if adminPassword == "" {
|
||||
return errors.New(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
|
||||
@@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error {
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
)
|
||||
}
|
||||
|
||||
// Контракт проверяется ЗДЕСЬ, а не только при хешировании.
|
||||
//
|
||||
// Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password
|
||||
// length exceeds 72 bytes» в journal — сообщение, по которому оператор не
|
||||
// поймёт ни что чинить, ни где. Причина же чинится одной строкой в
|
||||
// hy2xs.env, и назвать её обязан тот, кто знает про этот файл.
|
||||
if !credential.IsValidAdminPassword(adminPassword) {
|
||||
return fmt.Errorf(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
|
||||
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+
|
||||
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
|
||||
"Учётная запись администратора с таким значением создана НЕ будет: "+
|
||||
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
|
||||
"Исправьте значение в /etc/hy2xs/hy2xs.env и запустите\n"+
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
@@ -709,6 +740,7 @@ func tableExists(tableName string) bool {
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
|
||||
// придумывает его сам.
|
||||
//
|
||||
|
||||
@@ -57,9 +57,17 @@ export default tseslint.config(
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...autoImportGlobals,
|
||||
// Объявлены в src/types/global.d.ts и используются как глобальные типы.
|
||||
DialogType: "readonly",
|
||||
OptionType: "readonly",
|
||||
// Объявлены в src/types/global.d.ts и используются как глобальные
|
||||
// типы. Списку положено совпадать с содержимым того файла: `no-undef`
|
||||
// не знает о declare global, и отсутствующее здесь имя падает ошибкой
|
||||
// линтера при совершенно корректном коде.
|
||||
//
|
||||
// DialogType и OptionType отсюда убраны: в src/types/global.d.ts их
|
||||
// нет и в панели они не встречаются — это остаток чужого шаблона,
|
||||
// разрешавший необъявленные имена.
|
||||
IdDto: "readonly",
|
||||
BaseDto: "readonly",
|
||||
PageVo: "readonly",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"brace-expansion": ">=1.1.18",
|
||||
"picomatch": ">=2.3.2",
|
||||
"js-yaml": ">=4.3.1",
|
||||
"table>ajv": ">=8.18.0"
|
||||
"table>ajv": ">=8.18.0",
|
||||
"browserslist": "4.28.7"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -11,6 +11,7 @@ overrides:
|
||||
picomatch: '>=2.3.2'
|
||||
js-yaml: '>=4.3.1'
|
||||
table>ajv: '>=8.18.0'
|
||||
browserslist: 4.28.7
|
||||
|
||||
importers:
|
||||
|
||||
@@ -1175,6 +1176,11 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
baseline-browser-mapping@2.11.20:
|
||||
resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@@ -1186,8 +1192,8 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
browserslist@4.21.11:
|
||||
resolution: {integrity: sha512-xn1UXOKUz7DjdGlg9RrUr0GGiWzI97UQJnugHtH0OLDfJB7jMgoIkYvRIEO1l9EeEERVqeqLYOcFBW9ldjypbQ==}
|
||||
browserslist@4.28.7:
|
||||
resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
@@ -1209,6 +1215,9 @@ packages:
|
||||
caniuse-lite@1.0.30001539:
|
||||
resolution: {integrity: sha512-hfS5tE8bnNiNvEOEkm8HElUHroYwlqMMENEzELymy77+tJ6m+gA2krtHl5hxJaj71OlpC2cHZbdSMX1/YEqEkA==}
|
||||
|
||||
caniuse-lite@1.0.30001810:
|
||||
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
|
||||
|
||||
chalk@2.4.2:
|
||||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1343,8 +1352,8 @@ packages:
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
electron-to-chromium@1.4.529:
|
||||
resolution: {integrity: sha512-6uyPyXTo8lkv8SWAmjKFbG42U073TXlzD4R8rW3EzuznhFS2olCIAfjjQtV2dV2ar/vRF55KUd3zQYnCB0dd3A==}
|
||||
electron-to-chromium@1.5.420:
|
||||
resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==}
|
||||
|
||||
element-plus@2.14.5:
|
||||
resolution: {integrity: sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==}
|
||||
@@ -1390,8 +1399,8 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
escalade@3.1.1:
|
||||
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-string-regexp@1.0.5:
|
||||
@@ -1865,8 +1874,9 @@ packages:
|
||||
node-fetch-native@1.6.7:
|
||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||
|
||||
node-releases@2.0.13:
|
||||
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==}
|
||||
node-releases@2.0.54:
|
||||
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
@@ -2392,11 +2402,11 @@ packages:
|
||||
webpack:
|
||||
optional: true
|
||||
|
||||
update-browserslist-db@1.0.13:
|
||||
resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==}
|
||||
update-browserslist-db@1.3.2:
|
||||
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
browserslist: 4.28.7
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
@@ -3435,7 +3445,7 @@ snapshots:
|
||||
|
||||
autoprefixer@10.4.13(postcss@8.5.26):
|
||||
dependencies:
|
||||
browserslist: 4.21.11
|
||||
browserslist: 4.28.7
|
||||
caniuse-lite: 1.0.30001539
|
||||
fraction.js: 4.3.6
|
||||
normalize-range: 0.1.2
|
||||
@@ -3455,6 +3465,8 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
baseline-browser-mapping@2.11.20: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
@@ -3465,12 +3477,13 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
browserslist@4.21.11:
|
||||
browserslist@4.28.7:
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001539
|
||||
electron-to-chromium: 1.4.529
|
||||
node-releases: 2.0.13
|
||||
update-browserslist-db: 1.0.13(browserslist@4.21.11)
|
||||
baseline-browser-mapping: 2.11.20
|
||||
caniuse-lite: 1.0.30001810
|
||||
electron-to-chromium: 1.5.420
|
||||
node-releases: 2.0.54
|
||||
update-browserslist-db: 1.3.2(browserslist@4.28.7)
|
||||
|
||||
cac@7.0.0: {}
|
||||
|
||||
@@ -3491,6 +3504,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001539: {}
|
||||
|
||||
caniuse-lite@1.0.30001810: {}
|
||||
|
||||
chalk@2.4.2:
|
||||
dependencies:
|
||||
ansi-styles: 3.2.1
|
||||
@@ -3609,7 +3624,7 @@ snapshots:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.1.0
|
||||
|
||||
electron-to-chromium@1.4.529: {}
|
||||
electron-to-chromium@1.5.420: {}
|
||||
|
||||
element-plus@2.14.5(vue@3.5.42(typescript@5.9.3)):
|
||||
dependencies:
|
||||
@@ -3686,7 +3701,7 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.28.2
|
||||
'@esbuild/win32-x64': 0.28.2
|
||||
|
||||
escalade@3.1.1: {}
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-string-regexp@1.0.5: {}
|
||||
|
||||
@@ -4134,7 +4149,7 @@ snapshots:
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-releases@2.0.13: {}
|
||||
node-releases@2.0.54: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
@@ -4733,11 +4748,11 @@ snapshots:
|
||||
rollup: 4.63.1
|
||||
vite: 7.3.6(jiti@2.7.0)(sass@1.103.1)
|
||||
|
||||
update-browserslist-db@1.0.13(browserslist@4.21.11):
|
||||
update-browserslist-db@1.3.2(browserslist@4.28.7):
|
||||
dependencies:
|
||||
browserslist: 4.21.11
|
||||
escalade: 3.1.1
|
||||
picocolors: 1.0.0
|
||||
browserslist: 4.28.7
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import type {
|
||||
Hysteria2ServerConfig,
|
||||
Hysteria2ServerConfigOutbound,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Нормализация конфига Hysteria на границе API.
|
||||
*
|
||||
* Зачем этот файл существует.
|
||||
*
|
||||
* `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти
|
||||
* все его секции необязательны — потому что необязательны они и в upstream
|
||||
* YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`,
|
||||
* `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`.
|
||||
*
|
||||
* Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
|
||||
* Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он
|
||||
* прав: обращение через возможно отсутствующий объект в рантайме падает.
|
||||
* Спасало только то, что форма строится merge'ем поверх полного объекта
|
||||
* значений по умолчанию, то есть инвариант «секция есть всегда» существовал,
|
||||
* но держался на порядке присваиваний внутри компонента и нигде не был
|
||||
* выражен типом.
|
||||
*
|
||||
* Два способа это закрыть неверны:
|
||||
*
|
||||
* `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции
|
||||
* нет», не отвечая на него, и делает шаблон нечитаемым;
|
||||
*
|
||||
* `as any` — выключает ровно ту проверку, ради которой обновлялся
|
||||
* typechecker.
|
||||
*
|
||||
* Здесь выбран третий: одно преобразование на входе. Ответ приходит в
|
||||
* `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма
|
||||
* работает с `Hysteria2ServerConfigView`, где присутствие каждой секции —
|
||||
* свойство типа. Шаблону больше не нужно знать ни одного нюанса
|
||||
* необязательности upstream-схемы.
|
||||
*
|
||||
* Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка
|
||||
* серверного конфига идёт на backend от исходного YAML и сохраняет поля, о
|
||||
* которых HY2XS ещё не знает (см. docs/04). View-модель — только для
|
||||
* отображения, поэтому потеря неизвестных полей здесь безвредна.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DeepRequired делает обязательными все поля на всех уровнях.
|
||||
*
|
||||
* Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и
|
||||
* маппинг прошёлся бы по свойствам самого массива.
|
||||
*/
|
||||
type DeepRequired<T> = T extends (infer U)[]
|
||||
? DeepRequired<U>[]
|
||||
: T extends object
|
||||
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
|
||||
: T;
|
||||
|
||||
/** Конфиг Hysteria в том виде, в котором его показывает панель. */
|
||||
export type Hysteria2ServerConfigView = DeepRequired<Hysteria2ServerConfig>;
|
||||
|
||||
/** Один outbound в том же виде. */
|
||||
export type Hysteria2ServerConfigOutboundView =
|
||||
DeepRequired<Hysteria2ServerConfigOutbound>;
|
||||
|
||||
/**
|
||||
* Полное значение по умолчанию: каждая секция заполнена.
|
||||
*
|
||||
* Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и
|
||||
* добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь —
|
||||
* то есть новое поле upstream нельзя молча не отобразить.
|
||||
*/
|
||||
export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = {
|
||||
listen: ":443",
|
||||
tls: {
|
||||
cert: "",
|
||||
key: "",
|
||||
sniGuard: "",
|
||||
clientCA: "",
|
||||
},
|
||||
ech: {
|
||||
keyPath: "",
|
||||
},
|
||||
acme: {
|
||||
domains: [],
|
||||
email: "",
|
||||
ca: "letsencrypt",
|
||||
listenHost: "0.0.0.0",
|
||||
dir: "/var/lib/hysteria/acme",
|
||||
type: "",
|
||||
http: {
|
||||
altPort: 8888,
|
||||
},
|
||||
tls: {
|
||||
altPort: 44333,
|
||||
},
|
||||
dns: {
|
||||
name: "cloudflare",
|
||||
config: {},
|
||||
},
|
||||
disableHTTP: false,
|
||||
disableTLSALPN: false,
|
||||
altHTTPPort: 80,
|
||||
altTLSALPNPort: 443,
|
||||
},
|
||||
obfs: {
|
||||
type: "gecko",
|
||||
salamander: {
|
||||
password: "",
|
||||
},
|
||||
gecko: {
|
||||
password: "",
|
||||
minPacketSize: 512,
|
||||
maxPacketSize: 1200,
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: 8388608,
|
||||
maxStreamReceiveWindow: 8388608,
|
||||
initConnReceiveWindow: 20971520,
|
||||
maxConnReceiveWindow: 20971520,
|
||||
maxIdleTimeout: "30s",
|
||||
maxIncomingStreams: 1024,
|
||||
disablePathMTUDiscovery: false,
|
||||
disableStatelessReset: false,
|
||||
},
|
||||
bandwidth: {
|
||||
up: "50 mbps",
|
||||
down: "50 mbps",
|
||||
disableLossCompensation: false,
|
||||
},
|
||||
congestion: {
|
||||
type: "bbr",
|
||||
bbrProfile: "standard",
|
||||
},
|
||||
ignoreClientBandwidth: false,
|
||||
speedTest: false,
|
||||
disableUDP: false,
|
||||
udpIdleTimeout: "60s",
|
||||
resolver: {
|
||||
type: "",
|
||||
tcp: {
|
||||
addr: "8.8.8.8:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
udp: {
|
||||
addr: "8.8.4.4:53",
|
||||
timeout: "4s",
|
||||
},
|
||||
tls: {
|
||||
addr: "1.1.1.1:853",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
https: {
|
||||
addr: "1.1.1.1:443",
|
||||
timeout: "10s",
|
||||
sni: "cloudflare-dns.com",
|
||||
insecure: false,
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: true,
|
||||
timeout: "2s",
|
||||
rewriteDomain: false,
|
||||
tcpPorts: "80,443,8000-9000",
|
||||
udpPorts: "all",
|
||||
},
|
||||
acl: {
|
||||
file: "",
|
||||
inline: [],
|
||||
geoip: "",
|
||||
geosite: "",
|
||||
geoUpdateInterval: "168h",
|
||||
},
|
||||
outbounds: [],
|
||||
trafficStats: {
|
||||
listen: ":9999",
|
||||
},
|
||||
masquerade: {
|
||||
type: "",
|
||||
file: {
|
||||
dir: "",
|
||||
},
|
||||
proxy: {
|
||||
url: "",
|
||||
rewriteHost: true,
|
||||
insecure: false,
|
||||
xForwarded: false,
|
||||
},
|
||||
string: {
|
||||
content: "hello stupid world",
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
},
|
||||
listenHTTP: ":80",
|
||||
listenHTTPS: ":443",
|
||||
forceHTTPS: true,
|
||||
},
|
||||
mimic: {
|
||||
enabled: false,
|
||||
interface: "",
|
||||
xdpMode: "",
|
||||
path: "",
|
||||
extraArgs: [],
|
||||
},
|
||||
realm: {
|
||||
stunServers: [],
|
||||
stunTimeout: "",
|
||||
punchTimeout: "",
|
||||
heartbeatInterval: "",
|
||||
insecure: false,
|
||||
ipMode: "",
|
||||
portMapping: {
|
||||
enabled: false,
|
||||
timeout: "",
|
||||
lifetime: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивное наложение ответа сервера на значение по умолчанию.
|
||||
*
|
||||
* `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и
|
||||
* секция со значением `null` означают одно и то же — «не задано», — и обе
|
||||
* обязаны оставить значение по умолчанию, а не обнулить поле формы.
|
||||
*
|
||||
* Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил
|
||||
* или outbounds с сервера — это весь список, а не патч к дефолтному.
|
||||
*/
|
||||
function mergeInto(target: Record<string, unknown>, source: unknown): void {
|
||||
if (!isPlainObject(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const existing = target[key];
|
||||
if (!isPlainObject(existing)) {
|
||||
target[key] = {};
|
||||
}
|
||||
mergeInto(target[key] as Record<string, unknown>, value);
|
||||
continue;
|
||||
}
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneDefaults(): Hysteria2ServerConfigView {
|
||||
// structuredClone есть во всех целевых браузерах и, в отличие от
|
||||
// JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию.
|
||||
return structuredClone(defaultHysteria2ServerConfigView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит ответ сервера к модели, с которой работает форма.
|
||||
*
|
||||
* Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же
|
||||
* состояние, в котором форма находится до первого запроса.
|
||||
*/
|
||||
export function normalizeHysteriaViewModel(
|
||||
raw: Hysteria2ServerConfig | null | undefined
|
||||
): Hysteria2ServerConfigView {
|
||||
const view = cloneDefaults();
|
||||
mergeInto(view as unknown as Record<string, unknown>, raw);
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Значение по умолчанию для одного outbound. */
|
||||
export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView =
|
||||
{
|
||||
name: "",
|
||||
type: "socks5",
|
||||
socks5: {
|
||||
addr: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
http: {
|
||||
url: "",
|
||||
insecure: false,
|
||||
},
|
||||
direct: {
|
||||
mode: "auto",
|
||||
bindIPv4: "",
|
||||
bindIPv6: "",
|
||||
bindDevice: "",
|
||||
fastOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Тот же приём для одного outbound: список приходит с необязательными
|
||||
* подблоками, а карточка показывает их как обычные поля.
|
||||
*/
|
||||
export function normalizeOutboundViewModel(
|
||||
raw: Hysteria2ServerConfigOutbound | null | undefined
|
||||
): Hysteria2ServerConfigOutboundView {
|
||||
const view = structuredClone(defaultHysteria2ServerConfigOutboundView);
|
||||
mergeInto(view as unknown as Record<string, unknown>, raw);
|
||||
return view;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ConfigsDto,
|
||||
ConfigUpdateDto,
|
||||
ConfigVo,
|
||||
Hysteria2ServerConfig,
|
||||
Hysteria2ProfileVo,
|
||||
} from "@/api/config/types";
|
||||
|
||||
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
|
||||
// listConfigApi, на стороне сервера — по allowlist.
|
||||
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||
// Ответ описывает production-профиль, а не всю модель серверного конфига: он
|
||||
// отличает «не задано» от значения и отдельно перечисляет секции вне профиля.
|
||||
// Раньше сюда приезжала внутренняя модель целиком — вместе с паролем
|
||||
// обфускации, токенами ACME DNS и учётными данными outbound-прокси.
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ProfileVo> {
|
||||
return request({
|
||||
url: "/config/getHysteria2Config",
|
||||
method: "get",
|
||||
|
||||
@@ -20,188 +20,103 @@ export interface ConfigUpdateDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ.
|
||||
* Конфигурация Hysteria в терминах production-профиля HY2XS.
|
||||
*
|
||||
* Необязательность секций здесь не оплошность: ровно так устроен upstream YAML,
|
||||
* и модель обязана его повторять, иначе она врала бы об ответе сервера.
|
||||
* Что было. Здесь жил `Hysteria2ServerConfig` — полная модель серверного
|
||||
* конфига со всеми секциями upstream (resolver, sniff, acl, outbounds,
|
||||
* masquerade, mimic, realm, ech), а рядом, в `hysteriaViewModel.ts`, — её
|
||||
* копия с обязательными полями и полный объект значений по умолчанию, поверх
|
||||
* которого накладывался ответ сервера.
|
||||
*
|
||||
* Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из
|
||||
* `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию
|
||||
* живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они
|
||||
* выглядели частью протокола, хотя относятся исключительно к отображению.
|
||||
* Из-за этой конструкции экран отвечал не на тот вопрос. Отсутствующая секция
|
||||
* `trafficStats` показывалась как `:9999`, отсутствующий блок ACME — как набор
|
||||
* дефолтов UI, `speedTest: false` и `disableUDP: false` считались
|
||||
* ненастроенными и прятали свои вкладки. Диагностический экран скрывал ровно
|
||||
* тот дрейф, ради которого его открывают.
|
||||
*
|
||||
* Продуктом является ОДИН профиль: конфиг генерирует оркестратор и сам же
|
||||
* проверяет соответствие файла профилю. Поэтому панель показывает значения
|
||||
* профиля так, как они записаны, и отдельно перечисляет секции вне профиля.
|
||||
* Полный документ по-прежнему доступен санитизированной выгрузкой.
|
||||
*
|
||||
* `null` означает «в файле этого нет». Это единственный способ отличить
|
||||
* отсутствие от значения: `false`, `0` и пустая строка — законные значения.
|
||||
*/
|
||||
export interface Hysteria2ServerConfig {
|
||||
listen: string;
|
||||
tls?: {
|
||||
cert: string;
|
||||
key: string;
|
||||
sniGuard?: string;
|
||||
clientCA?: string;
|
||||
};
|
||||
ech?: {
|
||||
keyPath?: string;
|
||||
};
|
||||
acme?: {
|
||||
domains: string[];
|
||||
email: string;
|
||||
ca: string;
|
||||
listenHost: string;
|
||||
dir: string;
|
||||
type?: string;
|
||||
http?: {
|
||||
altPort: number;
|
||||
};
|
||||
tls?: {
|
||||
altPort: number;
|
||||
};
|
||||
dns?: {
|
||||
name: string;
|
||||
config: { [key: string]: string };
|
||||
};
|
||||
disableHTTP: boolean;
|
||||
disableTLSALPN: boolean;
|
||||
altHTTPPort: number;
|
||||
altTLSALPNPort: number;
|
||||
};
|
||||
obfs?: {
|
||||
type: string;
|
||||
salamander?: {
|
||||
password: string;
|
||||
};
|
||||
gecko?: {
|
||||
password: string;
|
||||
minPacketSize?: number;
|
||||
maxPacketSize?: number;
|
||||
};
|
||||
};
|
||||
quic?: {
|
||||
initStreamReceiveWindow?: number;
|
||||
maxStreamReceiveWindow?: number;
|
||||
initConnReceiveWindow?: number;
|
||||
maxConnReceiveWindow?: number;
|
||||
maxIdleTimeout?: string;
|
||||
maxIncomingStreams?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
disableStatelessReset?: boolean;
|
||||
};
|
||||
bandwidth?: {
|
||||
up: string;
|
||||
down: string;
|
||||
disableLossCompensation?: boolean;
|
||||
};
|
||||
congestion?: {
|
||||
type?: string;
|
||||
bbrProfile?: string;
|
||||
};
|
||||
ignoreClientBandwidth?: boolean;
|
||||
speedTest?: boolean;
|
||||
disableUDP?: boolean;
|
||||
udpIdleTimeout?: string;
|
||||
resolver?: {
|
||||
type: string;
|
||||
tcp?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
};
|
||||
udp?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
};
|
||||
tls?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
sni: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
https?: {
|
||||
addr: string;
|
||||
timeout: string;
|
||||
sni: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
};
|
||||
sniff?: {
|
||||
enable: boolean;
|
||||
timeout: string;
|
||||
rewriteDomain: boolean;
|
||||
tcpPorts?: string;
|
||||
udpPorts?: string;
|
||||
};
|
||||
acl?: {
|
||||
file?: string;
|
||||
inline?: string[];
|
||||
geoip?: string;
|
||||
geosite?: string;
|
||||
geoUpdateInterval?: string;
|
||||
};
|
||||
outbounds?: Hysteria2ServerConfigOutbound[];
|
||||
trafficStats: {
|
||||
listen: string;
|
||||
};
|
||||
masquerade?: {
|
||||
type: string;
|
||||
file?: {
|
||||
dir: string;
|
||||
};
|
||||
proxy?: {
|
||||
url: string;
|
||||
rewriteHost: boolean;
|
||||
insecure: boolean;
|
||||
xForwarded?: boolean;
|
||||
};
|
||||
string?: {
|
||||
content: string;
|
||||
headers?: { [key: string]: string };
|
||||
statusCode?: number;
|
||||
};
|
||||
listenHTTP?: string;
|
||||
listenHTTPS?: string;
|
||||
forceHTTPS?: boolean;
|
||||
};
|
||||
mimic?: {
|
||||
enabled?: boolean;
|
||||
interface?: string;
|
||||
xdpMode?: string;
|
||||
path?: string;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
realm?: {
|
||||
stunServers?: string[];
|
||||
stunTimeout?: string;
|
||||
punchTimeout?: string;
|
||||
heartbeatInterval?: string;
|
||||
insecure?: boolean;
|
||||
ipMode?: string;
|
||||
portMapping?: {
|
||||
enabled?: boolean;
|
||||
timeout?: string;
|
||||
lifetime?: string;
|
||||
};
|
||||
};
|
||||
export interface Hysteria2ProfileVo {
|
||||
listen: string | null;
|
||||
auth: Hysteria2ProfileAuth | null;
|
||||
tls: Hysteria2ProfileTls | null;
|
||||
acme: Hysteria2ProfileAcme | null;
|
||||
obfs: Hysteria2ProfileObfs | null;
|
||||
bandwidth: Hysteria2ProfileBandwidth | null;
|
||||
ignoreClientBandwidth: boolean | null;
|
||||
congestion: Hysteria2ProfileCongestion | null;
|
||||
quic: Hysteria2ProfileQuic | null;
|
||||
trafficStats: Hysteria2ProfileTrafficStats | null;
|
||||
/** Секции файла, которых production-профиль не описывает. */
|
||||
drift: string[];
|
||||
}
|
||||
|
||||
export interface Hysteria2ServerConfigOutbound {
|
||||
name: string;
|
||||
type: string;
|
||||
socks5?: {
|
||||
addr: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
http?: {
|
||||
url: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
direct?: {
|
||||
mode: string;
|
||||
bindIPv4?: string;
|
||||
bindIPv6?: string;
|
||||
bindDevice?: string;
|
||||
fastOpen?: boolean;
|
||||
};
|
||||
export interface Hysteria2ProfileAuth {
|
||||
type: string | null;
|
||||
/** Адрес backend-auth без machine token: он вырезан на сервере. */
|
||||
url: string | null;
|
||||
insecure: boolean | null;
|
||||
}
|
||||
|
||||
export interface Tab {
|
||||
name: string;
|
||||
desc: string;
|
||||
export interface Hysteria2ProfileTls {
|
||||
cert: string | null;
|
||||
key: string | null;
|
||||
sniGuard: string | null;
|
||||
clientCA: string | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileAcme {
|
||||
domains: string[];
|
||||
email: string | null;
|
||||
ca: string | null;
|
||||
dir: string | null;
|
||||
listenHost: string | null;
|
||||
type: string | null;
|
||||
dnsProvider: string | null;
|
||||
/** Имена параметров DNS-провайдера; значения на сервер не отдаются. */
|
||||
dnsConfigKeys: string[];
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileObfs {
|
||||
type: string | null;
|
||||
/**
|
||||
* Пароль обфускации в панель не приходит: он входит в клиентскую ссылку и
|
||||
* выдаётся там, где нужен. Диагностичен только факт, что он задан.
|
||||
*/
|
||||
passwordSet: boolean;
|
||||
minPacketSize: number | null;
|
||||
maxPacketSize: number | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileBandwidth {
|
||||
up: string | null;
|
||||
down: string | null;
|
||||
disableLossCompensation: boolean | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileCongestion {
|
||||
type: string | null;
|
||||
bbrProfile: string | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileQuic {
|
||||
initStreamReceiveWindow: number | null;
|
||||
maxStreamReceiveWindow: number | null;
|
||||
initConnReceiveWindow: number | null;
|
||||
maxConnReceiveWindow: number | null;
|
||||
maxIdleTimeout: string | null;
|
||||
maxIncomingStreams: number | null;
|
||||
disablePathMTUDiscovery: boolean | null;
|
||||
disableStatelessReset: boolean | null;
|
||||
}
|
||||
|
||||
export interface Hysteria2ProfileTrafficStats {
|
||||
listen: string | null;
|
||||
secretSet: boolean;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,23 @@ export interface DashboardSummaryVo {
|
||||
};
|
||||
hysteria: {
|
||||
version: string;
|
||||
/**
|
||||
* Состояние службы по systemd.
|
||||
*
|
||||
* `unknown` — это НЕ «остановлена»: `systemctl is-active` может не
|
||||
* ответить вовсе, и тогда о службе не известно ничего. Пока состояние было
|
||||
* булевым, второе выдавалось за первое, и оператор шёл перезапускать
|
||||
* работающий туннель.
|
||||
*/
|
||||
serviceState: "active" | "inactive" | "unknown";
|
||||
/** Ровно `serviceState === "active"`. */
|
||||
running: boolean;
|
||||
/**
|
||||
* Ответил ли Traffic Stats API на фактическое обращение.
|
||||
*
|
||||
* Независим от `serviceState`: раньше выводился из него и мог утверждать
|
||||
* «API доступен» при «служба остановлена», ни разу не сходив в API.
|
||||
*/
|
||||
apiReachable: boolean;
|
||||
lastApiError?: string;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,11 @@ export interface LogExportDto {
|
||||
|
||||
export interface LogSystemVo {
|
||||
clientIp: string;
|
||||
latencyTime: string;
|
||||
// Числа объявлены числами: сервер шлёт их из vo.LogSystemVo как int64
|
||||
// (latencyTime, statusCode), а не строками. Пока обе колонки не
|
||||
// отображались, расхождение было безвредным; после того как строка журнала
|
||||
// стала читаемой, оно стало бы обычной ошибкой сравнения или форматирования.
|
||||
latencyTime: number;
|
||||
level: string;
|
||||
msg: string;
|
||||
reqMethod: string;
|
||||
@@ -17,7 +21,7 @@ export interface LogSystemVo {
|
||||
reqPath: string;
|
||||
// Только имена переданных query-параметров, без значений.
|
||||
reqQueryKeys: string;
|
||||
statusCode: string;
|
||||
statusCode: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
KickPeerDto,
|
||||
PeerClientConfigVo,
|
||||
PeerPageDto,
|
||||
PeerPageVo,
|
||||
PeerSaveDto,
|
||||
PeerUpdateDto,
|
||||
PeerVo,
|
||||
@@ -16,15 +17,20 @@ export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
|
||||
});
|
||||
}
|
||||
|
||||
// Форма пира показывает причины отказа под своими полями, поэтому общий тост
|
||||
// ей не нужен: он повторял бы то же самое вторым сигналом.
|
||||
export function savePeerApi(data: PeerSaveDto): AxiosPromise {
|
||||
return request({
|
||||
url: "/peers",
|
||||
method: "post",
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
||||
// Ответ страницы описан собственным типом, а не общим PageVo<PeerVo>: кроме
|
||||
// записей он несёт признак того, известна ли вообще картина подключений.
|
||||
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PeerPageVo> {
|
||||
return request({
|
||||
url: "/peers",
|
||||
method: "get",
|
||||
@@ -32,10 +38,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Действия над строкой пира сообщают свой исход сами (см. reportPeerActionError
|
||||
// в списке пиров), поэтому общий тост им не нужен: он был бы вторым сигналом о
|
||||
// том же событии.
|
||||
//
|
||||
// Для отзыва доступа это не косметика. Операция умеет завершиться ЧАСТИЧНО —
|
||||
// запрет новых подключений записан, активную сессию завершить не удалось, — и
|
||||
// страница показывает такой исход предупреждением. Общий перехватчик показал бы
|
||||
// рядом ошибку, то есть два противоречащих друг другу сообщения об одном
|
||||
// результате.
|
||||
export function deletePeerApi(data: IdDto): AxiosPromise {
|
||||
return request({
|
||||
url: `/peers/${data.id}`,
|
||||
method: "delete",
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,6 +60,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
|
||||
url: `/peers/${data.id}`,
|
||||
method: "patch",
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,6 +68,7 @@ export function resetPeerTrafficApi(data: IdDto): AxiosPromise {
|
||||
return request({
|
||||
url: `/peers/${data.id}/reset-traffic`,
|
||||
method: "patch",
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +76,7 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise {
|
||||
return request({
|
||||
url: `/peers/${data.id}/release-kick`,
|
||||
method: "patch",
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +85,7 @@ export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise {
|
||||
url: `/peers/${id}/kick`,
|
||||
method: "patch",
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +98,15 @@ export function getPeerClientConfigApi(
|
||||
});
|
||||
}
|
||||
|
||||
// Импорт сообщает свой исход сам — по той же причине, что и действия строки
|
||||
// пира.
|
||||
//
|
||||
// Партия применяется одной транзакцией, а после её фиксации завершаются старые
|
||||
// сессии обновлённых пиров. Второй шаг умеет не удаться отдельно от первого, и
|
||||
// тогда ответ несёт peer_disconnect_failed: файл применён целиком, но часть
|
||||
// клиентов остаётся на связи до переподключения. Общий перехватчик показал бы
|
||||
// такой исход красной ошибкой, то есть сообщил бы оператору ровно обратное
|
||||
// тому, что произошло.
|
||||
export function importPeerApi(data: FormData): AxiosPromise {
|
||||
return request({
|
||||
url: "/peer-import",
|
||||
@@ -86,6 +115,7 @@ export function importPeerApi(data: FormData): AxiosPromise {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
data,
|
||||
skipErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -62,9 +62,31 @@ export type PeerVo = {
|
||||
onlineDevices: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Известна ли панели картина подключений прямо сейчас.
|
||||
*
|
||||
* Признак относится к ответу целиком, а не к строке: при `unavailable` поле
|
||||
* `online` в строках не означает ничего. Раньше отказ Traffic Stats API молча
|
||||
* превращался в «все офлайн» — ответ, уводящий оператора искать проблему у
|
||||
* пользователей.
|
||||
*/
|
||||
export type PeerOnlineState = "ok" | "unavailable";
|
||||
|
||||
export interface PeerPageVo {
|
||||
records: PeerVo[];
|
||||
total: number;
|
||||
onlineState: PeerOnlineState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентская ссылка пира.
|
||||
*
|
||||
* Поля `qrCode` здесь больше нет: QR рисуется в панели из самой ссылки
|
||||
* (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и вторым
|
||||
* способом получить то же самое.
|
||||
*/
|
||||
export interface PeerClientConfigVo {
|
||||
url: string;
|
||||
qrCode?: string | Uint8Array;
|
||||
}
|
||||
|
||||
export interface KickPeerDto {
|
||||
|
||||
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="#000000"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="#000000"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="currentColor"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="currentColor"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 768 B After Width: | Height: | Size: 783 B |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 440 B After Width: | Height: | Size: 445 B |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="#000000"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="#000000"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="currentColor"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="currentColor"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="#000000"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="currentColor"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="#000000"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="currentColor"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="#000000"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="#000000"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="currentColor"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="currentColor"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag v-for="item in tags" :key="item" size="large">
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<el-text v-if="!tags.length" type="info">—</el-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
|
||||
/**
|
||||
* Список строковых значений конфигурации: ACME-домены, inline-правила ACL.
|
||||
*
|
||||
* Компонент только показывает список, и это приведение UI в соответствие с тем,
|
||||
* что продукт умеет, а не сокращение возможностей.
|
||||
*
|
||||
* Что было. Здесь жил редактор на `vuedraggable`: перетаскивание тегов,
|
||||
* closable-теги с удалением, поле ввода нового значения и
|
||||
* `emit("update:tags")`. Сохранить он ничего не мог — единственное место
|
||||
* использования — форма конфига Hysteria, отрисованная с `:disabled="true"`,
|
||||
* причём значения передаются как `:tags="dataForm.acme.domains"`, без v-model,
|
||||
* то есть слушателя у события не существует. Маршрутов записи серверного
|
||||
* конфига в API нет.
|
||||
*
|
||||
* Цена этого редактора была измеримой. `vuedraggable` поставляется UMD-сборкой,
|
||||
* поэтому её `require("vue")` разрешался в `vue/dist/vue.cjs.prod.js` — полную
|
||||
* сборку Vue вместе с рантайм-компилятором шаблонов. В бандл уезжало:
|
||||
*
|
||||
* vuedraggable ~155 КБ
|
||||
* sortablejs ~115 КБ
|
||||
* @vue/compiler-core + compiler-dom ~230 КБ
|
||||
*
|
||||
* то есть около полумегабайта исходников ради перетаскивания тегов в форме,
|
||||
* которая недоступна для редактирования.
|
||||
*/
|
||||
const props = defineProps({
|
||||
tags: {
|
||||
required: false,
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const tags = computed(() => props.tags ?? []);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="load"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in LINE_OPTIONS"
|
||||
:key="option"
|
||||
:label="String(option)"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="load">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records" class="log-table">
|
||||
<!--
|
||||
Ширины заданы явно и по назначению колонки.
|
||||
|
||||
Без width/min-width Element Plus делит доступную ширину между
|
||||
колонками практически поровну: уровень и время получали по трети
|
||||
строки, а сообщение — тоже треть, хотя именно оно и является
|
||||
содержимым журнала. У Hysteria в msg приезжает диагностический JSON,
|
||||
поэтому колонка сообщения растягивается за счёт остатка, а служебные
|
||||
колонки зафиксированы.
|
||||
-->
|
||||
<el-table-column
|
||||
prop="level"
|
||||
label="level"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="msg" label="msg" min-width="640" align="left">
|
||||
<template #default="scope">
|
||||
<!--
|
||||
Сообщение ПЕРЕНОСИТСЯ, а не обрезается многоточием: у Hysteria
|
||||
это диагностический JSON, и обрезанная строка не отвечает ни на
|
||||
один вопрос, ради которого страницу открыли.
|
||||
-->
|
||||
<span class="log-message">{{ scope.row.msg }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="time" label="time" width="200" align="center" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Общая страница журнала: системного и Hysteria.
|
||||
*
|
||||
* Обе страницы были побайтово одинаковы, кроме вызываемого API, и обе несли
|
||||
* одни и те же три дефекта: колонки без ширин, экспорт с сетевым запросом ВНЕ
|
||||
* try и пустой catch, глушивший всё остальное. Починка в двух местах разошлась
|
||||
* бы снова — как разошлись форма ответа и обработка ошибок до этого.
|
||||
*
|
||||
* Различие между страницами вынесено в параметры: чем читать и что выгружать.
|
||||
*/
|
||||
import { AxiosPromise, AxiosResponse } from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { exportLogApi } from "@/api/log";
|
||||
import { LogDto } from "@/api/log/types";
|
||||
import { saveResponseAsFile } from "@/utils/download";
|
||||
|
||||
/** Строка журнала в объёме, который показывает таблица. */
|
||||
interface LogRow {
|
||||
level: string;
|
||||
msg: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Чтение страницы журнала. */
|
||||
fetchLogs: (query: LogDto) => AxiosPromise<PageVo<LogRow>>;
|
||||
/** Значение option для выгрузки: 0 — журнал админки, 1 — журнал Hysteria. */
|
||||
exportOption: number;
|
||||
/** Имя файла, если сервер его не назвал. */
|
||||
exportFileName: string;
|
||||
}>();
|
||||
|
||||
const LINE_OPTIONS = [100, 200, 300];
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const records = ref<LogRow[]>([]);
|
||||
const queryParams = reactive<LogDto>({ numLine: LINE_OPTIONS[0] });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await props.fetchLogs(queryParams);
|
||||
// Ответ обязан нести records на любой ветке — это контракт LogSystemPage.
|
||||
// Запасное значение здесь стоит не вместо контракта, а чтобы отказ формы
|
||||
// ответа не превращался в исключение внутри таблицы.
|
||||
records.value = data?.records ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выгрузка журнала.
|
||||
*
|
||||
* Сетевой запрос находится ВНУТРИ try. Раньше он стоял перед ним, поэтому его
|
||||
* отказ не ловился этим catch вовсе, а сам catch был пуст: оператор не получал
|
||||
* ни файла, ни причины.
|
||||
*
|
||||
* Отказ показывается ОДИН раз. Выгрузка приходит бинарным потоком и не
|
||||
* проходит через общий разбор конверта — у Blob нет полей code и errors, —
|
||||
* поэтому фразу здесь даёт сама страница.
|
||||
*/
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const response: AxiosResponse = await exportLogApi({
|
||||
option: props.exportOption,
|
||||
});
|
||||
saveResponseAsFile(response, props.exportFileName);
|
||||
ElMessage.success(t("common.downloadSuccess"));
|
||||
} catch {
|
||||
ElMessage.error(t("log.exportFailed"));
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-message {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
font-family: var(--el-font-family-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag v-for="entry in entries" :key="entry.key" size="large">
|
||||
{{ entry.key }}: {{ entry.value }}
|
||||
</el-tag>
|
||||
<el-text v-if="!entries.length" type="info">—</el-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "mapObject",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
|
||||
/**
|
||||
* Словарь «ключ — значение» из конфигурации: параметры ACME DNS-провайдера,
|
||||
* заголовки masquerade.
|
||||
*
|
||||
* Как и ImputMultiple, компонент только показывает содержимое. Здесь был
|
||||
* редактор с диалогом добавления, closable-тегами и `emit("update:mapObject")`,
|
||||
* и он ничего не сохранял по тем же трём причинам: форма конфига Hysteria
|
||||
* отрисована с `:disabled="true"`, значения передаются как
|
||||
* `:map-object="dataForm.acme.dns.config"` без v-model, маршрутов записи
|
||||
* серверного конфига в API нет.
|
||||
*
|
||||
* Значение показывается рядом с ключом: прежний вариант прятал его во второй
|
||||
* диалог по клику на тег, что для read-only страницы лишний шаг — увидеть
|
||||
* настройку сервера нужно сразу.
|
||||
*/
|
||||
const props = defineProps({
|
||||
mapObject: {
|
||||
required: false,
|
||||
type: Object as PropType<{ [key: string]: string }>,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const entries = computed(() =>
|
||||
Object.entries(props.mapObject ?? {}).map(([key, value]) => ({ key, value }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,42 @@
|
||||
<template>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
class="svg-icon"
|
||||
:style="'width:' + size + ';height:' + size"
|
||||
>
|
||||
<use :xlink:href="symbolId" :fill="color" />
|
||||
<use :xlink:href="symbolId" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SYMBOL_PREFIX } from "./symbol";
|
||||
|
||||
/**
|
||||
* Цвет иконке не передаётся — и это контракт, а не упущение.
|
||||
*
|
||||
* Раньше здесь были проп `color` и `:fill="color"` на `<use>`. Ими никто не
|
||||
* пользовался ни разу, а существование такого пропа приглашает чинить
|
||||
* сломанный цвет точечно: «вот этой иконке передадим белый». Монохромная
|
||||
* иконка обязана получать цвет ровно одним способом — наследованием
|
||||
* `currentColor` от компонента и темы; ассет, который так не умеет, чинится в
|
||||
* самом ассете и не доезжает до релиза (см. `symbol.ts`).
|
||||
*
|
||||
* Префикс id тоже больше не проп: он принадлежит спрайту, а не месту вызова, и
|
||||
* объявлен рядом с кодом, который этот id создаёт.
|
||||
*/
|
||||
const props = defineProps({
|
||||
prefix: {
|
||||
type: String,
|
||||
default: "icon",
|
||||
},
|
||||
iconClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "1em",
|
||||
},
|
||||
});
|
||||
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
|
||||
const symbolId = computed(() => `#${SYMBOL_PREFIX}-${props.iconClass}`);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
|
||||
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
|
||||
* сборке.
|
||||
*
|
||||
* Преобразование файла в `<symbol>` и контракт ассета живут в `./symbol.ts`:
|
||||
* там нет ни Vite, ни DOM, поэтому те же правила проверяются тестом и
|
||||
* релизным гейтом, а не только глазами на живой странице.
|
||||
*/
|
||||
|
||||
const SYMBOL_PREFIX = "icon";
|
||||
import { iconName, toSymbol } from "./symbol";
|
||||
|
||||
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
|
||||
|
||||
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
|
||||
@@ -33,68 +38,6 @@ const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
function iconName(filePath: string): string {
|
||||
return filePath.replace(/^.*\//, "").replace(/\.svg$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Превращает содержимое файла в `<symbol>`.
|
||||
*
|
||||
* Отбрасываются XML-пролог и DOCTYPE: внутри уже существующего документа они
|
||||
* не только бесполезны, но и делают разметку невалидной. `width` и `height`
|
||||
* тоже отбрасываются — размер задаёт компонент.
|
||||
*
|
||||
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
|
||||
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
|
||||
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
|
||||
* задают только width/height, поэтому viewBox для них синтезируется — ровно
|
||||
* так же, как это делал заменённый плагин.
|
||||
*/
|
||||
function toSymbol(raw: string, name: string): string {
|
||||
const withoutProlog = raw
|
||||
.replace(/<\?xml[\s\S]*?\?>/gi, "")
|
||||
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.trim();
|
||||
|
||||
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
|
||||
if (!openTag) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const body = withoutProlog
|
||||
.replace(/^<svg\b[^>]*>/i, "")
|
||||
.replace(/<\/svg>\s*$/i, "");
|
||||
|
||||
const viewBoxAttr = resolveViewBox(openTag[0]);
|
||||
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
|
||||
}
|
||||
|
||||
function resolveViewBox(openTag: string): string {
|
||||
const declared = openTag.match(/viewBox="([^"]+)"/i);
|
||||
if (declared) {
|
||||
return ` viewBox="${declared[1]}"`;
|
||||
}
|
||||
|
||||
const width = numericAttribute(openTag, "width");
|
||||
const height = numericAttribute(openTag, "height");
|
||||
if (width !== null && height !== null) {
|
||||
return ` viewBox="0 0 ${width} ${height}"`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
|
||||
function numericAttribute(openTag: string, name: string): number | null {
|
||||
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const value = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
|
||||
* содержимое, а не добавляет второй элемент с теми же id.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Превращение исходного SVG-файла в `<symbol>` и контракт, которому исходник
|
||||
* обязан соответствовать.
|
||||
*
|
||||
* Модуль намеренно ЧИСТЫЙ: ни `import.meta.glob`, ни `document`, ни любого
|
||||
* другого Vite/DOM API здесь нет. Сборка спрайта из файлов живёт в `sprite.ts`,
|
||||
* а сюда вынесено ровно то, что можно выполнить вне браузера и вне Vite —
|
||||
* то есть проверить тестом (`tools/test/frontend-sprite.test.ts`) и релизным
|
||||
* гейтом.
|
||||
*
|
||||
* Разделение появилось не ради красоты. Цвет иконок был сломан молча: контракт
|
||||
* `fill: currentcolor` существовал в двух местах (`SvgIcon/index.vue` и
|
||||
* `styles/sidebar.scss`), но восемь из семнадцати ассетов несли литеральный
|
||||
* атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления
|
||||
* перебивает унаследованное CSS-свойство. Все семь иконок бокового меню
|
||||
* рисовались чёрным по `--menuBg: #181818`. Ни одна существующая проверка
|
||||
* этого не видела, потому что проверять было нечего: сам файл иконки под
|
||||
* гейтом не был.
|
||||
*/
|
||||
|
||||
/** Префикс id у `<symbol>`; `SvgIcon` строит по нему `<use href="#icon-…">`. */
|
||||
export const SYMBOL_PREFIX = "icon";
|
||||
|
||||
/**
|
||||
* Иконки, которые многоцветны НАМЕРЕННО.
|
||||
*
|
||||
* Для них собственная палитра — часть ассета, а не дефект, поэтому проверка
|
||||
* цвета к ним не применяется. Список закрытый и явный: «многоцветность»
|
||||
* обязана быть решением, а не следствием того, что иконку скачали с готовыми
|
||||
* значениями fill.
|
||||
*
|
||||
* Всё остальное — монохромный UI: цвет наследуется от компонента и темы через
|
||||
* `currentColor`, и это единственный способ, которым иконка может получить
|
||||
* цвет. Ни CSS-фильтров, ни правил на конкретное имя иконки.
|
||||
*/
|
||||
export const MULTICOLOR_ICONS: ReadonlySet<string> = new Set([
|
||||
"download",
|
||||
"upload",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Значения `fill`/`stroke`, которые цветом не являются и потому разрешены
|
||||
* монохромной иконке.
|
||||
*
|
||||
* `none` — это «не закрашивать», а не цвет: у `refresh` контур рисуется
|
||||
* штрихом, и `fill="none"` там обязателен.
|
||||
*/
|
||||
const NON_COLOR_PAINT = new Set(["currentcolor", "none", "inherit", "transparent"]);
|
||||
|
||||
/** Атрибуты, любое литеральное значение которых задаёт цвет. */
|
||||
const PAINT_ATTRIBUTES = [
|
||||
"fill",
|
||||
"stroke",
|
||||
"stop-color",
|
||||
"flood-color",
|
||||
"lighting-color",
|
||||
];
|
||||
|
||||
/** `icons/log-system.svg` → `log-system`. */
|
||||
export function iconName(filePath: string): string {
|
||||
return filePath.replace(/^.*[\\/]/, "").replace(/\.svg$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает то, что внутри уже существующего документа не только бесполезно, но
|
||||
* и делает разметку невалидной: XML-пролог, DOCTYPE и комментарии.
|
||||
*/
|
||||
function stripProlog(raw: string): string {
|
||||
return raw
|
||||
.replace(/<\?xml[\s\S]*?\?>/gi, "")
|
||||
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Превращает содержимое файла в `<symbol>`.
|
||||
*
|
||||
* `width` и `height` отбрасываются вместе с корневым тегом — размер задаёт
|
||||
* компонент. Цвета НЕ переписываются: источник истины — сам файл, а
|
||||
* молчаливая нормализация в рантайме скрывала бы ровно тот дефект, который
|
||||
* этот модуль обязан делать видимым. За соответствие отвечает
|
||||
* `findIconContractViolations`, вызываемая тестом и релизным гейтом.
|
||||
*
|
||||
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
|
||||
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
|
||||
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
|
||||
* задают только width/height, поэтому viewBox для них синтезируется — ровно
|
||||
* так же, как это делал заменённый `vite-plugin-svg-icons`.
|
||||
*/
|
||||
export function toSymbol(raw: string, name: string): string {
|
||||
const withoutProlog = stripProlog(raw);
|
||||
|
||||
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
|
||||
if (!openTag) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const body = withoutProlog
|
||||
.replace(/^<svg\b[^>]*>/i, "")
|
||||
.replace(/<\/svg>\s*$/i, "");
|
||||
|
||||
const viewBoxAttr = resolveViewBox(openTag[0]);
|
||||
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
|
||||
}
|
||||
|
||||
export function resolveViewBox(openTag: string): string {
|
||||
const declared = openTag.match(/viewBox="([^"]+)"/i);
|
||||
if (declared) {
|
||||
return ` viewBox="${declared[1]}"`;
|
||||
}
|
||||
|
||||
const width = numericAttribute(openTag, "width");
|
||||
const height = numericAttribute(openTag, "height");
|
||||
if (width !== null && height !== null) {
|
||||
return ` viewBox="0 0 ${width} ${height}"`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
|
||||
function numericAttribute(openTag: string, name: string): number | null {
|
||||
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const value = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет ассет на соответствие контракту спрайта.
|
||||
*
|
||||
* Возвращает список нарушений; пустой список означает, что иконка пригодна.
|
||||
* Проверка одна на всех потребителей — тест и релизный гейт зовут её, а не
|
||||
* повторяют правила у себя. Второй экземпляр этих правил неизбежно разошёлся
|
||||
* бы с первым, и разошёлся бы молча.
|
||||
*/
|
||||
export function findIconContractViolations(raw: string, name: string): string[] {
|
||||
const violations: string[] = [];
|
||||
const source = stripProlog(raw);
|
||||
|
||||
const openTag = source.match(/<svg\b[^>]*>/i);
|
||||
if (!openTag) {
|
||||
return [`${name}: нет корневого <svg>`];
|
||||
}
|
||||
|
||||
// Система координат: либо объявленный viewBox, либо пара width/height, из
|
||||
// которой он синтезируется. Иконка без обоих способов сломала бы отрисовку
|
||||
// молча.
|
||||
if (!resolveViewBox(openTag[0])) {
|
||||
violations.push(`${name}: нет ни viewBox, ни пары width/height`);
|
||||
}
|
||||
|
||||
if (MULTICOLOR_ICONS.has(name)) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
for (const attribute of PAINT_ATTRIBUTES) {
|
||||
const pattern = new RegExp(`\\b${attribute}\\s*=\\s*"([^"]*)"`, "gi");
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const value = match[1].trim();
|
||||
if (value === "") {
|
||||
continue;
|
||||
}
|
||||
if (!NON_COLOR_PAINT.has(value.toLowerCase())) {
|
||||
violations.push(
|
||||
`${name}: атрибут ${attribute}="${value}" задаёт цвет мимо currentColor`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Инлайновый style бьёт и атрибут, и наследование, поэтому цвет в нём —
|
||||
// такое же нарушение контракта, как литеральный атрибут.
|
||||
for (const match of source.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)) {
|
||||
const declarations = match[1].toLowerCase();
|
||||
for (const attribute of PAINT_ATTRIBUTES) {
|
||||
const property = declarations.match(
|
||||
new RegExp(`(?:^|;)\\s*${attribute}\\s*:\\s*([^;]+)`)
|
||||
);
|
||||
if (property && !NON_COLOR_PAINT.has(property[1].trim())) {
|
||||
violations.push(
|
||||
`${name}: инлайновый style задаёт ${attribute}: ${property[1].trim()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Непустой <style> внутри ассета уезжает в документ вместе со спрайтом и
|
||||
// способен покрасить что угодно, включая чужие иконки: селекторы там
|
||||
// глобальные. Пустой блок остаётся от редакторов и безвреден.
|
||||
for (const match of source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
||||
if (match[1].trim() !== "") {
|
||||
violations.push(`${name}: непустой <style> внутри ассета`);
|
||||
}
|
||||
}
|
||||
|
||||
// Растр внутри иконки не наследует цвет ничем и никогда.
|
||||
if (/<image\b/i.test(source)) {
|
||||
violations.push(`${name}: растровое <image> не подчиняется currentColor`);
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div style="display: flex; align-items: center">
|
||||
<el-input-number
|
||||
v-model="capacity"
|
||||
placeholder="Please enter a value"
|
||||
:min="-1"
|
||||
:controls="false"
|
||||
:precision="0"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="unit"
|
||||
:placeholder="$t('peer.unit')"
|
||||
style="width: 100px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in units"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
import {
|
||||
calculateBytes,
|
||||
formatStorageCapacity,
|
||||
formatStorageUnit,
|
||||
} from "@/utils/byte";
|
||||
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
const props = defineProps({
|
||||
valueTmp: {
|
||||
type: Number as PropType<number>,
|
||||
required: true,
|
||||
},
|
||||
setValue: {
|
||||
type: Function as PropType<(newValue: number) => void>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
capacity: 0,
|
||||
unit: "GB",
|
||||
});
|
||||
|
||||
const { capacity, unit } = toRefs(state);
|
||||
|
||||
watch(
|
||||
[capacity, unit],
|
||||
([newC, newU]) => {
|
||||
const newValue = calculateBytes(newC, newU);
|
||||
props.setValue(newValue);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.valueTmp,
|
||||
(newValue) => {
|
||||
state.capacity = formatStorageCapacity(newValue);
|
||||
state.unit = formatStorageUnit(newValue);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Внутренние константы бренда.
|
||||
*
|
||||
* Единственное место, где живёт адрес атрибуции. Это не настройка: оператор
|
||||
* HY2XS не должен иметь возможности переназначить, куда ведёт подпись
|
||||
* разработчика, — ни через панель, ни через hy2xs.env, ни через таблицу
|
||||
* `config`. Поэтому значение принадлежит приложению и попадает в бандл при
|
||||
* сборке.
|
||||
*
|
||||
* По той же причине оно объявлено один раз, а не написано в шаблоне
|
||||
* компонента: литерал, размазанный по нескольким Vue-файлам, невозможно ни
|
||||
* проверить одним гейтом, ни изменить одной правкой.
|
||||
*
|
||||
* Отсутствие адреса в операторской конфигурации проверяется приёмкой сборки.
|
||||
*/
|
||||
export const FLAMY_NAME = "Flamy" as const;
|
||||
export const FLAMY_URL = "https://flamy.studio" as const;
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Контракт учётных данных администратора на стороне панели.
|
||||
*
|
||||
* Зачем этот модуль существует. Правила логина и пароля жили прямо в двух
|
||||
* формах и разошлись и с сервером, и друг с другом:
|
||||
*
|
||||
* форма входа логин 6-32 + набор символов, пароль 6-32 + набор
|
||||
* форма смены пароля пароль 6-64 + набор
|
||||
* сервер логин 6-32 + набор символов, пароль 6-64 без набора
|
||||
*
|
||||
* Следствий было два, и оба закрывали панель. Пароль, назначенный штатной
|
||||
* формой смены, мог оказаться длиннее 32 символов — и форма входа отказывалась
|
||||
* его отправлять: оператор терял доступ после операции, которую панель ему же и
|
||||
* предложила. А набор символов на пароле отвергал значение, которое сервер
|
||||
* принял бы, — панель была строже сервера там, где она не имеет на это права.
|
||||
*
|
||||
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
|
||||
* (apps/credential/admin.go) тестом tools/test/frontend-contract.test.ts.
|
||||
*/
|
||||
|
||||
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||||
export const ADMIN_USERNAME_MAX_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Набор символов логина в записи регекспа.
|
||||
*
|
||||
* Дефис ЭКРАНИРОВАН намеренно. В прежней записи `[a-zA-Z0-9!@#$%^&*()_+-=]` он
|
||||
* экранирован не был, поэтому `+-=` образовывал ДИАПАЗОН и молча впускал
|
||||
* `, - . / 0-9 : ; < =`. Действующий набор совпадает с этим фактическим
|
||||
* множеством — сужать его нельзя, оно уже работает на установленных
|
||||
* серверах, — но записан явно: пока он выглядел опечаткой, любая попытка
|
||||
* «навести порядок» развела бы панель и сервер обратно.
|
||||
*/
|
||||
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
|
||||
|
||||
export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||||
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
|
||||
);
|
||||
|
||||
/** Тот же набор в том виде, в каком его показывают оператору. */
|
||||
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||||
|
||||
/**
|
||||
* Границы пароля в СИМВОЛАХ Unicode. Набора символов у пароля НЕТ: сервер его
|
||||
* не ограничивает ни при установке, ни при смене, и панель не имеет права
|
||||
* отвергать значение, которое сервер принял бы.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||||
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Предел bcrypt — в БАЙТАХ, и он существует одновременно с границей в символах.
|
||||
*
|
||||
* `golang.org/x/crypto/bcrypt` отвечает `ErrPasswordTooLong` на пароль длиннее
|
||||
* 72 байт. Через границу в символах это не выражается: у 64 символов длина от
|
||||
* 64 до 256 байт в зависимости от алфавита.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MAX_BYTES = 72;
|
||||
|
||||
/**
|
||||
* Единственное правило пароля на стороне панели.
|
||||
*
|
||||
* Почему не встроенные `min`/`max` Element Plus. Правила формы Element Plus
|
||||
* делегирует библиотеке `async-validator`, а она сравнивает `min`/`max` строки
|
||||
* с `String.prototype.length`, то есть с числом единиц UTF-16. Go и оркестратор
|
||||
* считают code points. Расхождение не теоретическое:
|
||||
*
|
||||
* "😀😀😀" -> Go: 3 руны -> сервер отказывает (минимум 6)
|
||||
* JS: value.length = 6 -> форма считает минимум достигнутым
|
||||
*
|
||||
* то есть панель отправляла бы заведомо отвергаемый пароль и не могла бы
|
||||
* объяснить отказ. Обратная сторона того же — пароль из 40 эмодзи: 40 code
|
||||
* points укладываются в границу символов и не укладываются в 72 байта.
|
||||
*
|
||||
* Поэтому правило одно и считает ровно то же, что сервер: code points через
|
||||
* итератор строки (он идёт по code points, а не по единицам UTF-16) и байты
|
||||
* через `TextEncoder`, который кодирует именно в UTF-8.
|
||||
*
|
||||
* Ограничений НАБОРА в контракте два, и они разного происхождения.
|
||||
*
|
||||
* Первое — домен systemd. Первый пароль администратора уезжает в
|
||||
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
|
||||
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там —
|
||||
* это не предупреждение, а незагруженный файл окружения и, следовательно,
|
||||
* юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF,
|
||||
* суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Реализация
|
||||
* v257.13 случайно пропускает U+FEFF; панель следует документации. Иначе
|
||||
* она предложила бы оператору назначить пароль, после которого сервер
|
||||
* перестанет подниматься.
|
||||
*
|
||||
* Одиночные суррогаты проверяются отдельно и по своей причине: строка
|
||||
* JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
|
||||
* суррогат на U+FFFD — то есть без проверки на сервер уехал бы ДРУГОЙ пароль, а
|
||||
* не отказ.
|
||||
*
|
||||
* Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
|
||||
* DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них
|
||||
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
|
||||
* они умеют ровно одно — запереть оператора снаружи.
|
||||
*
|
||||
* Проверяются они перебором кодов, а не классом регекспа: литеральные
|
||||
* управляющие символы в исходнике невидимы, и такое правило нельзя проверить
|
||||
* глазами при ревью.
|
||||
*/
|
||||
export function isValidAdminPassword(value: string): boolean {
|
||||
let characters = 0;
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
|
||||
// Домен systemd: значение вне него не доедет до сервера вовсе.
|
||||
if (
|
||||
code === 0 ||
|
||||
code === 0xfeff ||
|
||||
(code >= 0xd800 && code <= 0xdfff) ||
|
||||
(code >= 0xfdd0 && code <= 0xfdef) ||
|
||||
(code & 0xfffe) === 0xfffe
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой.
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
characters += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
characters < ADMIN_PASSWORD_MIN_LENGTH ||
|
||||
characters > ADMIN_PASSWORD_MAX_LENGTH
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(value).length <= ADMIN_PASSWORD_MAX_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Правило пароля в том виде, в каком его принимает `el-form`.
|
||||
*
|
||||
* Собрано здесь, а не в каждой форме, по той же причине, по которой здесь живёт
|
||||
* сам контракт: у формы входа и формы смены пароля правило обязано быть одним и
|
||||
* тем же объектом кода, а не двумя совпадающими записями. Пока они были
|
||||
* двумя, они разошлись — 32 против 64 — и панель заперла оператора снаружи
|
||||
* после штатной смены пароля.
|
||||
*
|
||||
* Пустое значение пропускается: о нём отчитывается правило `required`, и без
|
||||
* этого оператор, отправивший пустую форму, читал бы про границы длины вместо
|
||||
* «поле обязательно». Сервер ведёт себя ровно так же — DTO приводит пустой
|
||||
* пароль к «не задано» до проверки правил.
|
||||
*
|
||||
* `message` передаётся аргументом: фраза принадлежит словарю локализации, а
|
||||
* `t()` доступен только внутри компонента.
|
||||
*/
|
||||
export function adminPasswordFormRule(message: string) {
|
||||
return {
|
||||
validator: (
|
||||
_rule: unknown,
|
||||
value: string,
|
||||
callback: (error?: Error) => void
|
||||
) => {
|
||||
if ((value ?? "") === "" || isValidAdminPassword(value)) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
callback(new Error(message));
|
||||
},
|
||||
trigger: ["change", "blur"] as string[],
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,13 @@ export default {
|
||||
password: "Password",
|
||||
login: "Login",
|
||||
capsLockOn: "Caps lock is On",
|
||||
usernameFormatIncorrect: "Username format is incorrect",
|
||||
passwordFormatIncorrect: "Password format is incorrect",
|
||||
},
|
||||
// Требования к учётным данным администратора: общие для формы входа и формы
|
||||
// смены пароля. См. комментарий в ru.ts.
|
||||
credentials: {
|
||||
usernameFormat: "Username: {min} to {max} characters from {charset}",
|
||||
passwordFormat:
|
||||
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale: "Dashboard data is stale. Retrying automatically...",
|
||||
@@ -44,8 +49,18 @@ export default {
|
||||
download: "Download",
|
||||
upload: "Upload",
|
||||
total: "Total",
|
||||
serviceStateLabel: "Hysteria service",
|
||||
serviceState: {
|
||||
active: "Running",
|
||||
inactive: "Stopped",
|
||||
unknown: "State unknown",
|
||||
},
|
||||
trafficApiLabel: "Traffic Stats API",
|
||||
trafficApiReachable: "Reachable",
|
||||
trafficApiUnreachable: "Unreachable",
|
||||
security: {
|
||||
hysteriaStopped: "Hysteria2 is stopped",
|
||||
hysteriaStateUnknown: "Hysteria2 service state is unknown: systemd did not answer",
|
||||
trafficApiUnavailable: "Traffic API is unavailable",
|
||||
},
|
||||
error: {
|
||||
@@ -90,9 +105,9 @@ export default {
|
||||
save: "Save",
|
||||
update: "Update",
|
||||
downloadSuccess: "Download successful",
|
||||
wait: "The version is being changed, please wait a moment",
|
||||
enableSuccess: "Hysteria2 start successful",
|
||||
disableSuccess: "Hysteria2 stop successful",
|
||||
// `wait`, `enableSuccess` and `disableSuccess` are gone together with the
|
||||
// operations the panel does not have: starting, stopping and changing the
|
||||
// version of Hysteria. That lifecycle belongs to systemd and the orchestrator.
|
||||
success: "Success",
|
||||
refresh: "Refresh",
|
||||
yes: "Yes",
|
||||
@@ -112,7 +127,77 @@ export default {
|
||||
invalid: "Invalid value",
|
||||
switchLanguageSuccess: "Language switched successfully",
|
||||
logoutConfirm: "Are you sure you want to log out?",
|
||||
sessionExpired: "Current session has expired, please log in again",
|
||||
sessionExpired: "Your session has expired. Sign in again to continue.",
|
||||
signInRequired: "Signing in is required.",
|
||||
signIn: "Sign in",
|
||||
systemError: "System error",
|
||||
networkError: "The server is not responding. Check the connection.",
|
||||
},
|
||||
error: {
|
||||
field: {
|
||||
name: "Peer name",
|
||||
secret: "Secret",
|
||||
remark: "Remark",
|
||||
quotaBytes: "Quota",
|
||||
expiresAt: "Expiry",
|
||||
maxDevices: "Max devices",
|
||||
disabled: "State",
|
||||
bannedUntil: "Banned until",
|
||||
file: "File",
|
||||
id: "Identifier",
|
||||
username: "Username",
|
||||
pass: "Password",
|
||||
oldPassword: "Old password",
|
||||
newPassword: "New password",
|
||||
key: "Setting key",
|
||||
value: "Setting value",
|
||||
numLine: "Line count",
|
||||
pageNum: "Page number",
|
||||
pageSize: "Page size",
|
||||
},
|
||||
code: {
|
||||
required: "“{field}”: required",
|
||||
min: "“{field}”: must not be less than {min}",
|
||||
max: "“{field}”: must not be greater than {max}",
|
||||
min_length: "“{field}”: at least {min} characters",
|
||||
max_length: "“{field}”: at most {max} characters",
|
||||
len: "“{field}”: length must be exactly {len}",
|
||||
oneof: "“{field}”: allowed values are {values}",
|
||||
gt: "“{field}”: must be greater than {gt}",
|
||||
peer_name:
|
||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||
credential_format: "“{field}”: {min} to {max} characters from {charset}",
|
||||
admin_password_format:
|
||||
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
|
||||
rule_violated: "“{field}”: value is not acceptable",
|
||||
validation_failed: "Validation failed",
|
||||
body_invalid:
|
||||
"Request could not be parsed: check field formats and types",
|
||||
peer_name_taken: "A peer with this name already exists",
|
||||
peer_name_reserved: "This name is reserved for the installer peer",
|
||||
peer_bootstrap_identity_locked:
|
||||
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
|
||||
invalid_credentials: "Wrong username or password",
|
||||
// The phrase must open with what has ALREADY been applied, otherwise it
|
||||
// reads as "the operation failed" and the operator repeats an action
|
||||
// that in fact went through.
|
||||
//
|
||||
// It also names no specific operation. This code is reported by
|
||||
// disabling a peer, a temporary ban, secret rotation, quota and expiry
|
||||
// reductions, a lower device limit, a batch import and peer deletion;
|
||||
// the previous "new connections for this peer are now refused" held only
|
||||
// for the first case and is meaningless for a deleted peer.
|
||||
peer_disconnect_failed:
|
||||
"The changes were saved, but the related active sessions could not be terminated: the Hysteria Traffic Stats API is unreachable. Established connections may keep working until the client reconnects. Check the hysteria-server service and retry.",
|
||||
import_file_extension: "Import accepts .json files only",
|
||||
unauthorized: "Signing in is required",
|
||||
session_expired: "Session expired",
|
||||
token_invalid: "Session is not valid",
|
||||
account_disabled: "Account is disabled",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
developedBy: "Made at {brand}",
|
||||
},
|
||||
info: {
|
||||
expireTime: "y-M-d H:m:s",
|
||||
@@ -128,12 +213,14 @@ export default {
|
||||
remark: "Remark",
|
||||
secret: "Secret",
|
||||
form: {
|
||||
namePlaceholder: "e.g. ivan-laptop",
|
||||
namePlaceholder: "client-01",
|
||||
// The character set is stated as service.IsValidPeerName accepts it. The
|
||||
// previous wording described the rule that was in force before EX-03.
|
||||
nameHint:
|
||||
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
||||
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
|
||||
"Peer identifier: 6 to 32 characters — latin letters, digits and !@#$%^&*()_+-=. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
|
||||
remarkPlaceholder: "laptop",
|
||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||
secretPlaceholder: "leave empty to generate automatically",
|
||||
secretPlaceholder: "leave empty to generate one",
|
||||
secretHint:
|
||||
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
|
||||
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
|
||||
@@ -168,16 +255,31 @@ export default {
|
||||
onlineStatus: "Online Status",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
onlineUnknown: "Online unknown",
|
||||
onlineUnavailable: "Live connection state is currently unavailable",
|
||||
onlineUnavailableHint:
|
||||
"The Hysteria Traffic Stats API did not answer, so online status and device counts are unknown. Stored peer state in the table is accurate.",
|
||||
device: "Online Devices",
|
||||
unit: "Unit",
|
||||
loginAt: "Last login time",
|
||||
conAt: "Last connection time",
|
||||
createTime: "Create Time",
|
||||
releaseSuccess: "Release successful",
|
||||
kick: "Kick",
|
||||
kickTip: "Force user to log off",
|
||||
releaseKick: "Release",
|
||||
releaseKickTip: "Remove offline status",
|
||||
kick: "Ban temporarily",
|
||||
kickTip:
|
||||
"Drop the session and refuse new connections until the ban expires",
|
||||
kickConfirm:
|
||||
"Ban peer “{name}” until {until}? The current session will be dropped and new connections refused until then.",
|
||||
kickSuccess: "Peer banned temporarily",
|
||||
releaseKick: "Lift ban",
|
||||
releaseKickTip: "Lift the temporary ban early",
|
||||
// Disabling and the temporary ban are separate mechanisms: the ban expires
|
||||
// on its own, disabling is only undone by hand.
|
||||
enablePeer: "Enable peer",
|
||||
disablePeer: "Disable peer",
|
||||
disableConfirm:
|
||||
"Disable peer “{name}”? The current session will be dropped and new connections refused until the peer is enabled again.",
|
||||
enableSuccess: "Peer enabled",
|
||||
disableSuccess: "Peer disabled",
|
||||
createdAt: "Created at",
|
||||
bannedUntil: "Banned until",
|
||||
totalTraffic: "Total traffic",
|
||||
@@ -192,22 +294,15 @@ export default {
|
||||
resetTrafficMonth: "Run once a month, midnight, first of month",
|
||||
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
||||
},
|
||||
monitor: {
|
||||
cpuPercent: "CPU Usage",
|
||||
memPercent: "Memory Usage",
|
||||
diskPercent: "Disk Usage",
|
||||
hysteria2UserTotal: "Number of online users",
|
||||
hysteria2DeviceTotal: "Number of online devices",
|
||||
hysteria2Version: "Hysteria2 Version",
|
||||
hysteria2Running: "Hysteria2 Status",
|
||||
hysteria2RunningTrue: "Running",
|
||||
hysteria2RunningFalse: "Stop",
|
||||
},
|
||||
// The `monitor` section is gone together with its only consumer — the
|
||||
// "Hysteria2 Status" tag on the config page. The service state has three
|
||||
// values, not two, and its phrases live in `dashboard.serviceState`.
|
||||
log: {
|
||||
numLine: "Number of lines",
|
||||
level: "Level",
|
||||
message: "Message",
|
||||
time: "Time",
|
||||
exportFailed: "Could not export the log",
|
||||
},
|
||||
errorPage: {
|
||||
back: "Back",
|
||||
@@ -222,201 +317,34 @@ export default {
|
||||
notFoundBackHome: "Back to home",
|
||||
},
|
||||
hysteria: {
|
||||
enable: "Enable",
|
||||
disable: "Disable",
|
||||
addConfigItem: "Add Config Item",
|
||||
hysteria2Version: "Hysteria2 Version",
|
||||
hysteria2Running: "Hysteria2 Status",
|
||||
outboundsEmpty: "No outbounds are configured on the server",
|
||||
listen: "Listen",
|
||||
ownedByOrchestrator: "The Hysteria configuration is owned by the installer",
|
||||
ownedByOrchestratorHint:
|
||||
"The panel shows /etc/hysteria/config.yaml read-only. Changes are applied by `hy2xs-orchestrator reconfigure`.",
|
||||
listen: "Listen address",
|
||||
auth: "Peer authentication",
|
||||
tls: "TLS",
|
||||
obfs: "Obfuscation",
|
||||
quic: "QUIC parameters",
|
||||
bandwidth: "Bandwidth",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Speed Test",
|
||||
udp: "UDP",
|
||||
resolver: "Resolver",
|
||||
sniff: "Protocol Sniffing",
|
||||
acl: "ACL",
|
||||
outbounds: "Outbounds",
|
||||
http: "Traffic Stats API (HTTP)",
|
||||
masquerade: "Masquerade",
|
||||
config: {
|
||||
listen:
|
||||
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
|
||||
tlsType: "TLS type",
|
||||
tls: {
|
||||
cert: "The path to the Cert file.",
|
||||
key: "The path to the Key file.",
|
||||
sniGuard:
|
||||
'Verify the SNI provided by the client. Accept the connection only when it matches what\'s in the certificate. Terminate the TLS handshake otherwise. Set to strict to enforce this behavior. Set to disable to disable this entirely. The default is dns-san, which enables this feature only when the certificate contains the "Subject Alternative Name" extension with a domain name in it.',
|
||||
},
|
||||
acme: {
|
||||
domains: "Domains",
|
||||
email: "Email",
|
||||
ca: "The CA to use. Can be letsencrypt or zerossl.",
|
||||
listenHost:
|
||||
"The host address (not including the port) to listen on for the ACME challenge. If omitted, the server will listen on all interfaces.",
|
||||
dir: "The directory to store the ACME account key and certificates.",
|
||||
type: "ACME challenge type. Can be http, tls, or dns.",
|
||||
http: {
|
||||
altPort:
|
||||
"Listening port for HTTP challenges. (Note: Changing to a port other than 80 requires port forwarding or HTTP reverse proxy, or the challenge will fail!)",
|
||||
},
|
||||
tls: {
|
||||
altPort:
|
||||
"Listening port for TLS-ALPN challenges. (Note: Changing to a port other than 443 requires port forwarding or TLS reverse proxy, or the challenge will fail!)",
|
||||
},
|
||||
dns: {
|
||||
name: "DNS provider. For details, refer to ACME DNS Configuration.",
|
||||
config: "ACME DNS Configuration",
|
||||
},
|
||||
disableHTTP: "Disable HTTP challenge.",
|
||||
disableTLSALPN: "Disable TLS-ALPN challenge.",
|
||||
altHTTPPort:
|
||||
"Alternate HTTP challenge port. (Note: If you want to use anything other than 80, you must set up port forward/HTTP reverse proxy from 80 to that port, otherwise ACME will not be able to issue the certificate.)",
|
||||
altTLSALPNPort:
|
||||
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
|
||||
},
|
||||
obfs: {
|
||||
type: "Obfuscation type: gecko (HY2XS production default) or salamander (compatibility fallback).",
|
||||
salamander: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
},
|
||||
gecko: {
|
||||
password: "Replace with a strong password of your choice.",
|
||||
minPacketSize:
|
||||
"Minimum QUIC handshake fragment size. HY2XS baseline: 512.",
|
||||
maxPacketSize:
|
||||
"Maximum QUIC handshake fragment size. HY2XS baseline: 1200, upstream limit: 2048.",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
|
||||
maxStreamReceiveWindow: "The maximum QUIC stream receive window size.",
|
||||
initConnReceiveWindow:
|
||||
"The initial QUIC connection receive window size.",
|
||||
maxConnReceiveWindow:
|
||||
"The maximum QUIC connection receive window size.",
|
||||
maxIdleTimeout:
|
||||
"The maximum idle timeout. How long the server will consider the client still connected without any activity.",
|
||||
maxIncomingStreams:
|
||||
"The maximum number of concurrent incoming streams.",
|
||||
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
|
||||
disableStatelessReset:
|
||||
"Disable QUIC stateless reset. Kept off in the HY2XS baseline: stateless reset lets a client with a stale connection reconnect immediately after a server restart or device sleep.",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
disableLossCompensation:
|
||||
"Disable loss compensation. Kept off in the HY2XS baseline, so compensation stays active.",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr or reno. Used when Brutal bandwidth is not negotiated by both sides.",
|
||||
bbrProfile: "BBR profile: standard, conservative or aggressive.",
|
||||
},
|
||||
ignoreClientBandwidth:
|
||||
"When enabled, makes the server to disregard any bandwidth hints set by clients",
|
||||
speedTest:
|
||||
"speedTest enables the built-in speed test server. When enabled, clients can test their download and upload speeds with the server. For more information, see the Speed Test documentation.",
|
||||
disableUDP:
|
||||
"disableUDP disables UDP forwarding, only allowing TCP connections.",
|
||||
udpIdleTimeout:
|
||||
"udpIdleTimeout specifies the amount of time the server will keep a local UDP port open for each UDP session that has no activity. This is conceptually similar to the NAT UDP session timeout.",
|
||||
resolver: {
|
||||
type: "Type",
|
||||
tcp: {
|
||||
addr: "The address of the TCP resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
},
|
||||
udp: {
|
||||
addr: "The address of the UDP resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
},
|
||||
tls: {
|
||||
addr: "The address of the TLS resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
sni: "The SNI to use for the TLS resolver.",
|
||||
insecure: "Disable TLS verification for the TLS resolver.",
|
||||
},
|
||||
https: {
|
||||
addr: "The address of the HTTPS resolver.",
|
||||
timeout: "The timeout for DNS queries.",
|
||||
sni: "The SNI to use for the TLS resolver.",
|
||||
insecure: "Disable TLS verification for the TLS resolver.",
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: "Whether to enable protocol sniffing.",
|
||||
timeout:
|
||||
"Sniffing timeout. If the protocol/domain cannot be determined within this time, the original address will be used to initiate the connection.",
|
||||
rewriteDomain:
|
||||
"Whether to rewrite requests that are already in domain name form. If enabled, requests with the target address already in domain name form will still be sniffed.",
|
||||
tcpPorts:
|
||||
"List of TCP ports. Only TCP requests on these ports will be sniffed.",
|
||||
udpPorts:
|
||||
"List of UDP ports. Only UDP requests on these ports will be sniffed.",
|
||||
},
|
||||
aclType: "ACL type",
|
||||
acl: {
|
||||
file: "The path to the ACL file.",
|
||||
inline: "The list of inline ACL rules.",
|
||||
geoip:
|
||||
"Optional. Uncomment to enable. The path to the GeoIP database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
|
||||
geosite:
|
||||
"Optional. Uncomment to enable. The path to the GeoSite database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
|
||||
geoUpdateInterval:
|
||||
"Optional. The interval at which to refresh the GeoIP/GeoSite databases. 168 hours (1 week) by default. Only applies if the GeoIP/GeoSite databases are automatically downloaded. (Check the note below for more information.)",
|
||||
},
|
||||
outbounds: {
|
||||
name: "The name of the outbound. This is used in ACL rules.",
|
||||
type: "Type",
|
||||
socks5: {
|
||||
addr: "The address of the SOCKS5 proxy.",
|
||||
username:
|
||||
"Optional. The username for the SOCKS5 proxy, if authentication is required.",
|
||||
password:
|
||||
"Optional. The password for the SOCKS5 proxy, if authentication is required.",
|
||||
},
|
||||
http: {
|
||||
url: "The URL of the HTTP/HTTPS proxy. (Can be http:// or https://)",
|
||||
insecure:
|
||||
"Optional. Whether to disable TLS verification. Applies to HTTPS proxies only.",
|
||||
},
|
||||
direct: {
|
||||
mode: "Type",
|
||||
bindIPv4: "The local IPv4 address to bind to.",
|
||||
bindIPv6: "The local IPv6 address to bind to.",
|
||||
bindDevice: "The local network interface to bind to.",
|
||||
fastOpen: "Enable TCP fast open.",
|
||||
},
|
||||
},
|
||||
trafficStats: {
|
||||
listen: "The address to listen on.",
|
||||
},
|
||||
masquerade: {
|
||||
type: "Type",
|
||||
file: {
|
||||
dir: "The directory to serve files from.",
|
||||
},
|
||||
proxy: {
|
||||
url: "The URL of the website to proxy.",
|
||||
rewriteHost:
|
||||
"Whether to rewrite the Host header to match the proxied website. This is required if the target web server uses Host to determine which site to serve.",
|
||||
insecure: "Disable TLS verification for the proxied website.",
|
||||
},
|
||||
string: {
|
||||
content: "The string to return.",
|
||||
headers: "Optional. The headers to return.",
|
||||
statusCode: "Optional. The status code to return. 200 by default.",
|
||||
},
|
||||
listenHTTP: "HTTP (TCP) listen address.",
|
||||
listenHTTPS: "HTTPS (TCP) listen address.",
|
||||
forceHTTPS:
|
||||
"Whether to force HTTPS. If enabled, all HTTP requests will be redirected to HTTPS.",
|
||||
},
|
||||
},
|
||||
trafficStats: "Traffic Stats API",
|
||||
notSet: "not set",
|
||||
sectionMissing: "The section is absent from the configuration",
|
||||
tlsMissing: "Neither tls nor acme is configured",
|
||||
valuesHidden: "values are not shown",
|
||||
secretSet: "set",
|
||||
secretMissing: "not set",
|
||||
obfsPasswordHint: "handed out in the peer share link",
|
||||
driftTitle: "Configuration drift",
|
||||
driftHint:
|
||||
"The file contains sections outside the HY2XS production profile. The orchestrator neither creates nor supports them: the configuration was most likely edited by hand.",
|
||||
trafficStatsMissing: "Traffic Stats API is not configured",
|
||||
trafficStatsMissingHint:
|
||||
"Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
|
||||
trafficStatsUnreachable:
|
||||
"the panel cannot reach this address: the Traffic Stats API is queried through 127.0.0.1 only. While the addresses differ, the device limit, traffic accounting and forced disconnect do not work",
|
||||
trafficStatsWildcard:
|
||||
"the API is reachable but listens on every interface. The HY2XS production profile uses 127.0.0.1: run hy2xs-orchestrator reconfigure",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,8 +19,24 @@ export default {
|
||||
password: "Пароль",
|
||||
login: "Войти",
|
||||
capsLockOn: "Caps Lock включён",
|
||||
usernameFormatIncorrect: "Неверный формат логина",
|
||||
passwordFormatIncorrect: "Неверный формат пароля",
|
||||
},
|
||||
// Требования к учётным данным администратора. Фразы общие для формы входа и
|
||||
// формы смены пароля: требование одно, и второй его формулировки быть не
|
||||
// должно — расхождение здесь означало бы, что оператору обещают разное про
|
||||
// одно и то же поле.
|
||||
//
|
||||
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
|
||||
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
|
||||
// одного способа узнать, что именно от него хотят: набор символов логина
|
||||
// приходит из hy2xs.env, и посмотреть его в панели негде.
|
||||
credentials: {
|
||||
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
|
||||
// Фраза называет ОБЕ границы, потому что их две и они в разных единицах.
|
||||
// Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72
|
||||
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
|
||||
// сокращать пароль, отвергнутый не за это.
|
||||
passwordFormat:
|
||||
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
},
|
||||
dashboard: {
|
||||
stale:
|
||||
@@ -43,8 +59,21 @@ export default {
|
||||
download: "Скачано",
|
||||
upload: "Отдано",
|
||||
total: "Всего",
|
||||
serviceStateLabel: "Служба Hysteria",
|
||||
// Три состояния, а не два: «спросить systemd не удалось» — это не
|
||||
// «служба остановлена», и действия оператора у них разные.
|
||||
serviceState: {
|
||||
active: "Работает",
|
||||
inactive: "Остановлена",
|
||||
unknown: "Состояние неизвестно",
|
||||
},
|
||||
trafficApiLabel: "Traffic Stats API",
|
||||
trafficApiReachable: "Доступен",
|
||||
trafficApiUnreachable: "Недоступен",
|
||||
security: {
|
||||
hysteriaStopped: "Hysteria2 остановлена",
|
||||
hysteriaStateUnknown:
|
||||
"Состояние службы Hysteria2 неизвестно: systemd не ответил",
|
||||
trafficApiUnavailable: "Traffic API недоступен",
|
||||
},
|
||||
error: {
|
||||
@@ -87,9 +116,9 @@ export default {
|
||||
save: "Сохранить",
|
||||
update: "Обновить",
|
||||
downloadSuccess: "Загрузка завершена",
|
||||
wait: "Версия меняется, подождите",
|
||||
enableSuccess: "Hysteria2 запущена",
|
||||
disableSuccess: "Hysteria2 остановлена",
|
||||
// Фразы `wait`, `enableSuccess` и `disableSuccess` удалены вместе с
|
||||
// операциями, которых у панели нет: запуском, остановкой и сменой версии
|
||||
// Hysteria. Жизненный цикл принадлежит systemd и оркестратору.
|
||||
success: "Готово",
|
||||
refresh: "Обновить",
|
||||
yes: "Да",
|
||||
@@ -109,7 +138,88 @@ export default {
|
||||
invalid: "Некорректное значение",
|
||||
switchLanguageSuccess: "Язык переключён",
|
||||
logoutConfirm: "Выйти из системы?",
|
||||
sessionExpired: "Текущая сессия истекла, войдите снова",
|
||||
sessionExpired: "Сессия истекла. Войдите снова, чтобы продолжить.",
|
||||
signInRequired: "Требуется вход в панель.",
|
||||
signIn: "Войти",
|
||||
systemError: "Системная ошибка",
|
||||
networkError: "Сервер не отвечает. Проверьте соединение с панелью.",
|
||||
},
|
||||
// Причины отказа API.
|
||||
//
|
||||
// Ключи строятся из КОДА ответа, а не из его текста: панель не разбирает
|
||||
// человеческие сообщения сервера. Числа правил приходят в параметрах, поэтому
|
||||
// второй копии границ длины здесь нет — она неизбежно разошлась бы с
|
||||
// серверной.
|
||||
error: {
|
||||
field: {
|
||||
name: "Имя пира",
|
||||
secret: "Секрет",
|
||||
remark: "Комментарий",
|
||||
quotaBytes: "Квота",
|
||||
expiresAt: "Срок действия",
|
||||
maxDevices: "Лимит устройств",
|
||||
disabled: "Состояние",
|
||||
bannedUntil: "Блокировка до",
|
||||
file: "Файл",
|
||||
id: "Идентификатор",
|
||||
username: "Логин",
|
||||
pass: "Пароль",
|
||||
oldPassword: "Старый пароль",
|
||||
newPassword: "Новый пароль",
|
||||
key: "Ключ настройки",
|
||||
value: "Значение настройки",
|
||||
numLine: "Число строк",
|
||||
pageNum: "Номер страницы",
|
||||
pageSize: "Размер страницы",
|
||||
},
|
||||
code: {
|
||||
required: "«{field}»: поле обязательно",
|
||||
min: "«{field}»: значение не может быть меньше {min}",
|
||||
max: "«{field}»: значение не может быть больше {max}",
|
||||
min_length: "«{field}»: не короче {min} символов",
|
||||
max_length: "«{field}»: не длиннее {max} символов",
|
||||
len: "«{field}»: длина должна быть ровно {len}",
|
||||
oneof: "«{field}»: допустимые значения — {values}",
|
||||
gt: "«{field}»: значение должно быть больше {gt}",
|
||||
peer_name:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
|
||||
// Сервер присылает границы и набор в params — фраза называет требование
|
||||
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
|
||||
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
|
||||
credential_format:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}",
|
||||
admin_password_format:
|
||||
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
|
||||
rule_violated: "«{field}»: значение не подходит",
|
||||
validation_failed: "Проверка данных не пройдена",
|
||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||
peer_name_taken: "Пир с таким именем уже существует",
|
||||
peer_name_reserved: "Это имя зарезервировано за пиром установщика",
|
||||
peer_bootstrap_identity_locked:
|
||||
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
|
||||
invalid_credentials: "Неверный логин или пароль",
|
||||
// Фраза обязана начинаться с того, что УЖЕ СДЕЛАНО: иначе оператор
|
||||
// прочитает её как «операция не выполнена» и повторит действие, которое
|
||||
// на самом деле применилось.
|
||||
//
|
||||
// И она НЕ называет конкретную операцию. Этим кодом отчитываются
|
||||
// отключение пира, временная блокировка, смена секрета, урезание квоты и
|
||||
// срока, снижение лимита устройств, импорт партии и удаление пира;
|
||||
// прежнее «новые подключения пира запрещены» было верно ровно для
|
||||
// первого случая, а для удалённого пира — просто бессмысленно.
|
||||
peer_disconnect_failed:
|
||||
"Изменения сохранены, но завершить связанные активные сессии не удалось: Traffic Stats API Hysteria недоступен. Установленные соединения могут работать до переподключения клиента. Проверьте состояние службы hysteria-server и повторите действие.",
|
||||
import_file_extension: "Импорт принимает только файлы .json",
|
||||
unauthorized: "Требуется вход в панель",
|
||||
session_expired: "Сессия истекла",
|
||||
token_invalid: "Сессия недействительна",
|
||||
account_disabled: "Учётная запись отключена",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
// {brand} подставляется ссылкой, поэтому фраза обязана остаться одной
|
||||
// строкой с одним подстановочным местом.
|
||||
developedBy: "Разработано во {brand}",
|
||||
},
|
||||
info: {
|
||||
expireTime: "г-М-д Ч:м:с",
|
||||
@@ -124,12 +234,16 @@ export default {
|
||||
remark: "Комментарий",
|
||||
secret: "Секрет",
|
||||
form: {
|
||||
namePlaceholder: "например, ivan-laptop",
|
||||
namePlaceholder: "client-01",
|
||||
// Набор символов назван так, как его понимает service.IsValidPeerName.
|
||||
// Прежняя формулировка «латиница, цифры и дефис» осталась от правила,
|
||||
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
|
||||
// сервер принимает, и оператор не имел причин пробовать остальные.
|
||||
nameHint:
|
||||
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
|
||||
"Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||
remarkPlaceholder: "ноутбук",
|
||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
|
||||
secretPlaceholder: "оставьте пустым — сгенерируем",
|
||||
secretHint:
|
||||
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
|
||||
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
|
||||
@@ -164,21 +278,35 @@ export default {
|
||||
onlineStatus: "Онлайн",
|
||||
online: "Онлайн",
|
||||
offline: "Офлайн",
|
||||
onlineUnknown: "Онлайн неизвестен",
|
||||
onlineUnavailable: "Картина подключений сейчас недоступна",
|
||||
onlineUnavailableHint:
|
||||
"Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.",
|
||||
device: "Устройства",
|
||||
createdAt: "Создан",
|
||||
bannedUntil: "Блокировка до",
|
||||
totalTraffic: "Суммарный трафик",
|
||||
copyUri: "Копировать URI",
|
||||
more: "Ещё",
|
||||
unit: "Ед. изм.",
|
||||
loginAt: "Последний вход",
|
||||
conAt: "Последнее подключение",
|
||||
createTime: "Создано",
|
||||
releaseSuccess: "Ограничение снято",
|
||||
kick: "Отключить",
|
||||
kickTip: "Принудительно отключить пользователя",
|
||||
releaseKick: "Снять",
|
||||
releaseKickTip: "Снять офлайн-статус",
|
||||
kick: "Временно заблокировать",
|
||||
kickTip: "Прервать соединение и не пускать до истечения срока",
|
||||
kickConfirm:
|
||||
"Заблокировать пир «{name}» до {until}? Текущее соединение будет разорвано, новые подключения до этого времени приниматься не будут.",
|
||||
kickSuccess: "Пир временно заблокирован",
|
||||
releaseKick: "Снять блокировку",
|
||||
releaseKickTip: "Снять временную блокировку досрочно",
|
||||
// Отключение и временная блокировка — разные механизмы: у блокировки есть
|
||||
// срок, который истекает сам, отключение снимается только руками.
|
||||
enablePeer: "Включить пир",
|
||||
disablePeer: "Отключить пир",
|
||||
disableConfirm:
|
||||
"Отключить пир «{name}»? Текущее соединение будет разорвано, а новые подключения запрещены, пока пир не будет включён обратно.",
|
||||
enableSuccess: "Пир включён",
|
||||
disableSuccess: "Пир отключён",
|
||||
},
|
||||
config: {
|
||||
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
|
||||
@@ -188,22 +316,16 @@ export default {
|
||||
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
||||
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
||||
},
|
||||
monitor: {
|
||||
cpuPercent: "CPU",
|
||||
memPercent: "Память",
|
||||
diskPercent: "Диск",
|
||||
hysteria2UserTotal: "Пользователей онлайн",
|
||||
hysteria2DeviceTotal: "Устройств онлайн",
|
||||
hysteria2Version: "Версия Hysteria2",
|
||||
hysteria2Running: "Статус Hysteria2",
|
||||
hysteria2RunningTrue: "Работает",
|
||||
hysteria2RunningFalse: "Остановлена",
|
||||
},
|
||||
// Раздел `monitor` удалён вместе со своим единственным потребителем —
|
||||
// плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы
|
||||
// имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`:
|
||||
// «остановлена» и «неизвестно» — разные ответы оператору.
|
||||
log: {
|
||||
numLine: "Количество строк",
|
||||
level: "Уровень",
|
||||
message: "Сообщение",
|
||||
time: "Время",
|
||||
exportFailed: "Не удалось выгрузить журнал",
|
||||
},
|
||||
errorPage: {
|
||||
back: "Назад",
|
||||
@@ -218,157 +340,42 @@ export default {
|
||||
notFoundBackHome: "На главную",
|
||||
},
|
||||
hysteria: {
|
||||
enable: "Включить",
|
||||
disable: "Отключить",
|
||||
addConfigItem: "Добавить параметр",
|
||||
hysteria2Version: "Версия Hysteria2",
|
||||
hysteria2Running: "Статус Hysteria2",
|
||||
// addOutbound удалён вместе с редактором outbounds: страница read-only,
|
||||
// маршрутов записи серверного конфига в API нет.
|
||||
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
|
||||
ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик",
|
||||
ownedByOrchestratorHint:
|
||||
"Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.",
|
||||
// Разделы production-профиля. Экран показывает то, что записано в файле,
|
||||
// и отдельно называет секции сверх профиля: универсального редактора всех
|
||||
// возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор.
|
||||
listen: "Адрес прослушивания",
|
||||
auth: "Авторизация пиров",
|
||||
tls: "TLS",
|
||||
obfs: "Маскировка",
|
||||
quic: "Параметры QUIC",
|
||||
bandwidth: "Полоса",
|
||||
congestion: "Congestion control",
|
||||
speedTest: "Тест скорости",
|
||||
udp: "UDP",
|
||||
resolver: "DNS",
|
||||
sniff: "Sniffing протоколов",
|
||||
acl: "ACL",
|
||||
outbounds: "Outbounds",
|
||||
http: "Traffic Stats API (HTTP)",
|
||||
masquerade: "Masquerade",
|
||||
config: {
|
||||
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
|
||||
tlsType: "Тип TLS",
|
||||
tls: {
|
||||
cert: "Путь к cert-файлу",
|
||||
key: "Путь к key-файлу",
|
||||
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.",
|
||||
},
|
||||
acme: {
|
||||
domains: "Домены",
|
||||
email: "Email",
|
||||
ca: "CA: letsencrypt или zerossl",
|
||||
listenHost: "Адрес для ACME challenge",
|
||||
dir: "Каталог ACME аккаунта и сертификатов",
|
||||
type: "Тип ACME challenge: http, tls или dns",
|
||||
http: { altPort: "Альтернативный порт HTTP challenge" },
|
||||
tls: { altPort: "Альтернативный порт TLS-ALPN challenge" },
|
||||
dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" },
|
||||
disableHTTP: "Отключить HTTP challenge",
|
||||
disableTLSALPN: "Отключить TLS-ALPN challenge",
|
||||
altHTTPPort: "Альтернативный HTTP-порт",
|
||||
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
|
||||
},
|
||||
obfs: {
|
||||
type: "Тип обфускации: gecko (production default HY2XS) или salamander (совместимость)",
|
||||
salamander: { password: "Сильный пароль Salamander" },
|
||||
gecko: {
|
||||
password: "Сильный пароль Gecko",
|
||||
minPacketSize:
|
||||
"Минимальный размер фрагмента QUIC handshake. Baseline HY2XS: 512",
|
||||
maxPacketSize:
|
||||
"Максимальный размер фрагмента QUIC handshake. Baseline HY2XS: 1200, верхний предел upstream: 2048",
|
||||
},
|
||||
},
|
||||
quic: {
|
||||
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
|
||||
maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream",
|
||||
initConnReceiveWindow: "Начальное окно приёма QUIC connection",
|
||||
maxConnReceiveWindow: "Максимальное окно приёма QUIC connection",
|
||||
maxIdleTimeout: "Максимальный idle timeout",
|
||||
maxIncomingStreams: "Максимум входящих stream",
|
||||
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
|
||||
disableStatelessReset:
|
||||
"Отключить QUIC stateless reset. В baseline HY2XS выключено: stateless reset ускоряет переподключение клиента после перезапуска сервера или сна устройства",
|
||||
},
|
||||
bandwidth: {
|
||||
up: "Вверх",
|
||||
down: "Вниз",
|
||||
disableLossCompensation:
|
||||
"Отключить компенсацию потерь. В baseline HY2XS выключено, то есть компенсация работает",
|
||||
},
|
||||
congestion: {
|
||||
type: "Fallback congestion controller: bbr или reno. Применяется, когда Brutal bandwidth не согласован сторонами",
|
||||
bbrProfile: "Профиль BBR: standard, conservative или aggressive",
|
||||
},
|
||||
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
|
||||
speedTest: "Встроенный сервер теста скорости",
|
||||
disableUDP: "Отключить UDP forwarding",
|
||||
udpIdleTimeout: "Idle timeout для UDP-сессий",
|
||||
resolver: {
|
||||
type: "Тип",
|
||||
tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" },
|
||||
udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" },
|
||||
tls: {
|
||||
addr: "DNS over TLS resolver",
|
||||
timeout: "Timeout DNS-запросов",
|
||||
sni: "SNI для TLS resolver",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
https: {
|
||||
addr: "DNS over HTTPS resolver",
|
||||
timeout: "Timeout DNS-запросов",
|
||||
sni: "SNI для HTTPS resolver",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
},
|
||||
sniff: {
|
||||
enable: "Включить sniffing",
|
||||
timeout: "Timeout sniffing",
|
||||
rewriteDomain: "Повторно анализировать доменные запросы",
|
||||
tcpPorts: "TCP-порты для sniffing",
|
||||
udpPorts: "UDP-порты для sniffing",
|
||||
},
|
||||
aclType: "Тип ACL",
|
||||
acl: {
|
||||
file: "Путь к ACL-файлу",
|
||||
inline: "Inline ACL-правила",
|
||||
geoip: "Путь к GeoIP базе",
|
||||
geosite: "Путь к GeoSite базе",
|
||||
geoUpdateInterval: "Интервал обновления GeoIP/GeoSite",
|
||||
},
|
||||
outbounds: {
|
||||
name: "Имя outbound",
|
||||
type: "Тип",
|
||||
socks5: {
|
||||
addr: "Адрес SOCKS5",
|
||||
username: "Логин SOCKS5",
|
||||
password: "Пароль SOCKS5",
|
||||
},
|
||||
http: {
|
||||
url: "URL HTTP/HTTPS proxy",
|
||||
insecure: "Отключить TLS-проверку proxy",
|
||||
},
|
||||
direct: {
|
||||
mode: "Тип",
|
||||
bindIPv4: "Локальный IPv4",
|
||||
bindIPv6: "Локальный IPv6",
|
||||
bindDevice: "Сетевой интерфейс",
|
||||
fastOpen: "TCP fast open",
|
||||
},
|
||||
},
|
||||
trafficStats: { listen: "Адрес прослушивания" },
|
||||
masquerade: {
|
||||
type: "Тип",
|
||||
file: { dir: "Каталог файлов" },
|
||||
proxy: {
|
||||
url: "URL проксируемого сайта",
|
||||
rewriteHost: "Переписывать Host header",
|
||||
insecure: "Отключить TLS-проверку",
|
||||
},
|
||||
string: {
|
||||
content: "Ответ строкой",
|
||||
headers: "HTTP headers",
|
||||
statusCode: "HTTP status code",
|
||||
},
|
||||
listenHTTP: "HTTP listen address",
|
||||
listenHTTPS: "HTTPS listen address",
|
||||
forceHTTPS: "Принудительно использовать HTTPS",
|
||||
},
|
||||
},
|
||||
trafficStats: "Traffic Stats API",
|
||||
notSet: "не задано",
|
||||
sectionMissing: "Секция в конфигурации отсутствует",
|
||||
tlsMissing: "Ни tls, ни acme в конфигурации не заданы",
|
||||
valuesHidden: "значения не показываются",
|
||||
secretSet: "задан",
|
||||
secretMissing: "не задан",
|
||||
obfsPasswordHint: "выдаётся в клиентской ссылке пира",
|
||||
driftTitle: "Расхождение конфигурации",
|
||||
driftHint:
|
||||
"В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.",
|
||||
trafficStatsMissing: "Traffic Stats API не настроен",
|
||||
trafficStatsMissingHint:
|
||||
"Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
|
||||
// Две разные новости об одном поле, и объединять их нельзя: первая
|
||||
// означает, что доступ пиров уже не работает, вторая — что API работает, но
|
||||
// опубликован шире необходимого. Прочие адреса loopback (127.0.0.5 и
|
||||
// подобные) относятся к ПЕРВОЙ: слушатель на конкретном адресе соединения
|
||||
// на 127.0.0.1 не принимает.
|
||||
trafficStatsUnreachable:
|
||||
"панель не может обратиться по этому адресу: Traffic Stats API запрашивается строго через 127.0.0.1. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
|
||||
trafficStatsWildcard:
|
||||
"API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,17 +28,24 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
|
||||
/**
|
||||
* Выход из системы.
|
||||
*
|
||||
* Отмена диалога — это ОТВЕТ оператора, а не ошибка. ElMessageBox отклоняет
|
||||
* промис при нажатии «Отмена», и цепочка без разбора отказа оставляла в
|
||||
* консоли необработанное отклонение промиса на каждый передумавший выход.
|
||||
*/
|
||||
function logout() {
|
||||
ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
|
||||
confirmButtonText: t("navbar.logout"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
adminStore.logout().then(() => {
|
||||
router.push(`/login?redirect=${route.fullPath}`);
|
||||
async function logout() {
|
||||
try {
|
||||
await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
|
||||
confirmButtonText: t("navbar.logout"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
type: "warning",
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await adminStore.logout();
|
||||
router.push(`/login?redirect=${route.fullPath}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { FLAMY_NAME, FLAMY_URL } from "@/constants/branding";
|
||||
|
||||
defineProps({
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-footer" :class="{ 'is-collapsed': collapse }">
|
||||
<!--
|
||||
Свёрнутое меню шириной 54px не вмещает фразу целиком, поэтому в нём
|
||||
остаётся только имя-ссылка. Прятать подпись совсем нельзя: атрибуция
|
||||
обязана быть видна в обоих состояниях.
|
||||
-->
|
||||
<a
|
||||
v-if="collapse"
|
||||
class="sidebar-footer-brand"
|
||||
:href="FLAMY_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ FLAMY_NAME }}</a
|
||||
>
|
||||
<i18n-t v-else keypath="sidebar.developedBy" tag="span" scope="global">
|
||||
<template #brand>
|
||||
<a
|
||||
class="sidebar-footer-brand"
|
||||
:href="FLAMY_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ FLAMY_NAME }}</a
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: $sidebarFooterHeight;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: rgb(255 255 255 / 45%);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
background-color: var(--menuBg);
|
||||
border-top: 1px solid rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.sidebar-footer.is-collapsed {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sidebar-footer-brand {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { useRoute } from "vue-router";
|
||||
|
||||
import SidebarItem from "./SidebarItem.vue";
|
||||
import Logo from "./Logo.vue";
|
||||
import Footer from "./Footer.vue";
|
||||
|
||||
import { usePermissionStore } from "@/store/modules/permission";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
@@ -36,5 +37,6 @@ const route = useRoute();
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<Footer :collapse="!appStore.sidebar.opened" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,12 +38,22 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Область прокрутки меню ограничена сверху логотипом, снизу — подписью
|
||||
// разработчика. Пункты меню поэтому не могут наехать на подпись даже при
|
||||
// длинном списке: им физически некуда.
|
||||
&.has-logo {
|
||||
.el-scrollbar {
|
||||
height: calc(100% - 50px);
|
||||
height: calc(100% - 50px - #{$sidebarFooterHeight});
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
|
||||
|
||||
$sideBarWidth: 210px;
|
||||
$sideBarCollapsedWidth: 54px;
|
||||
|
||||
// Высота подписи разработчика внизу бокового меню.
|
||||
//
|
||||
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
|
||||
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
|
||||
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
|
||||
$sidebarFooterHeight: 34px;
|
||||
|
||||
@@ -17,6 +17,8 @@ declare module 'vue' {
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
@@ -37,14 +39,12 @@ declare module 'vue' {
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElText: typeof import('element-plus/es')['ElText']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
||||
@@ -53,14 +53,12 @@ declare module 'vue' {
|
||||
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
|
||||
IEpRefresh: typeof import('~icons/ep/refresh')['default']
|
||||
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
||||
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
|
||||
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
||||
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
|
||||
LogViewer: typeof import('./../components/LogViewer/index.vue')['default']
|
||||
Pagination: typeof import('./../components/Pagination/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
|
||||
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Разбор структурированного отказа API.
|
||||
*
|
||||
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
|
||||
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
|
||||
* панель могла сделать, — показать это слово тостом. Оператор, оставивший поле
|
||||
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
|
||||
* не имел ни одного способа узнать причину.
|
||||
*
|
||||
* Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель
|
||||
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
|
||||
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
|
||||
*/
|
||||
|
||||
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
|
||||
export const API_CODE = {
|
||||
success: 20000,
|
||||
systemError: 50000,
|
||||
validationFailed: 50001,
|
||||
unauthorized: 50401,
|
||||
forbidden: 50403,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
|
||||
*
|
||||
* Перечислены только те, на которые панель реагирует по-разному. Остальные
|
||||
* доезжают до оператора сообщением сервера.
|
||||
*/
|
||||
export const ERR_CODE = {
|
||||
bodyInvalid: "body_invalid",
|
||||
validationFailed: "validation_failed",
|
||||
required: "required",
|
||||
min: "min",
|
||||
max: "max",
|
||||
// Границы числа и границы длины строки различаются кодом, хотя тег
|
||||
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
|
||||
// разные фразы для оператора.
|
||||
minLength: "min_length",
|
||||
maxLength: "max_length",
|
||||
len: "len",
|
||||
oneOf: "oneof",
|
||||
greaterThan: "gt",
|
||||
ruleViolated: "rule_violated",
|
||||
peerName: "peer_name",
|
||||
credentialFormat: "credential_format",
|
||||
// Правило пароля администратора целиком: длина в символах, длина в байтах
|
||||
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
|
||||
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
|
||||
// пароля, укладывающегося в границу символов.
|
||||
adminPasswordFormat: "admin_password_format",
|
||||
peerNameTaken: "peer_name_taken",
|
||||
peerNameReserved: "peer_name_reserved",
|
||||
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
||||
// ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых
|
||||
// подключений записан, активную сессию завершить не удалось. Панель
|
||||
// показывает его предупреждением и обновляет строку — состояние в базе уже
|
||||
// изменилось.
|
||||
peerDisconnectFailed: "peer_disconnect_failed",
|
||||
invalidCredentials: "invalid_credentials",
|
||||
importFileExtension: "import_file_extension",
|
||||
unauthorized: "unauthorized",
|
||||
sessionExpired: "session_expired",
|
||||
tokenInvalid: "token_invalid",
|
||||
accountDisabled: "account_disabled",
|
||||
} as const;
|
||||
|
||||
export interface ApiFieldError {
|
||||
code: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
code: number;
|
||||
message?: string;
|
||||
errors?: ApiFieldError[];
|
||||
}
|
||||
|
||||
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
|
||||
export class ApiError extends Error {
|
||||
readonly code: number;
|
||||
readonly errors: ApiFieldError[];
|
||||
|
||||
constructor(payload: ApiErrorPayload) {
|
||||
super(payload.message || "Error");
|
||||
this.name = "ApiError";
|
||||
this.code = payload.code;
|
||||
this.errors = payload.errors ?? [];
|
||||
}
|
||||
|
||||
/** Причины, привязанные к полям формы. */
|
||||
fieldErrors(): ApiFieldError[] {
|
||||
return this.errors.filter((item) => !!item.field);
|
||||
}
|
||||
|
||||
/** Первая причина без привязки к полю — отказ уровня операции. */
|
||||
operationError(): ApiFieldError | undefined {
|
||||
return this.errors.find((item) => !item.field);
|
||||
}
|
||||
|
||||
hasCode(code: string): boolean {
|
||||
return this.errors.some((item) => item.code === code);
|
||||
}
|
||||
|
||||
get requiresSignIn(): boolean {
|
||||
return this.code === API_CODE.unauthorized;
|
||||
}
|
||||
|
||||
get sessionExpired(): boolean {
|
||||
return (
|
||||
this.hasCode(ERR_CODE.sessionExpired) ||
|
||||
this.hasCode(ERR_CODE.accountDisabled)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(value: unknown): value is ApiError {
|
||||
return value instanceof ApiError;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import i18n from "@/lang/index";
|
||||
import { ApiError, ApiFieldError } from "@/utils/api-error";
|
||||
|
||||
/**
|
||||
* Локализация причины отказа.
|
||||
*
|
||||
* Ключ строится ИЗ КОДА, а не из текста ответа. Сервер присылает и своё
|
||||
* человекочитаемое сообщение — оно остаётся ответом для клиента без панели и
|
||||
* запасным вариантом здесь: код, которого панель ещё не знает, обязан доехать
|
||||
* до оператора хоть в каком-то виде, а не превратиться в пустую строку.
|
||||
*
|
||||
* Числа правил (границы длины, допустимые значения) приходят в `params`.
|
||||
* Второй копии этих чисел в панели нет намеренно: копия неизбежно разошлась бы
|
||||
* с серверной, и оператор читал бы «от 6 до 128», получая отказ по другим
|
||||
* границам.
|
||||
*/
|
||||
|
||||
const t = i18n.global.t;
|
||||
const te = i18n.global.te;
|
||||
|
||||
/** Локализованное название поля формы; при отсутствии — имя из ответа. */
|
||||
function fieldLabel(field: string): string {
|
||||
const key = `error.field.${field}`;
|
||||
return te(key) ? t(key) : field;
|
||||
}
|
||||
|
||||
/** Сообщение по одной причине отказа. */
|
||||
export function describeFieldError(error: ApiFieldError): string {
|
||||
const key = `error.code.${error.code}`;
|
||||
if (te(key)) {
|
||||
return t(key, {
|
||||
field: error.field ? fieldLabel(error.field) : "",
|
||||
...(error.params ?? {}),
|
||||
});
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
/** Причины по именам полей формы — для подстановки в el-form. */
|
||||
export function fieldErrorMap(error: ApiError): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const item of error.fieldErrors()) {
|
||||
// Первая причина по полю выигрывает: показывать в одном поле две строки
|
||||
// некуда, а порядок ответа отражает порядок правил.
|
||||
if (item.field && !(item.field in result)) {
|
||||
result[item.field] = describeFieldError(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна строка, пригодная для тоста.
|
||||
*
|
||||
* Отказ уровня операции показывается как есть. Отказ по полям сворачивается в
|
||||
* перечисление «поле: причина» — тост при этом остаётся вторым сигналом, а
|
||||
* первым служит подсветка самих полей.
|
||||
*/
|
||||
export function describeApiError(error: ApiError): string {
|
||||
const operation = error.operationError();
|
||||
if (operation) {
|
||||
return describeFieldError(operation);
|
||||
}
|
||||
|
||||
const fields = error.fieldErrors();
|
||||
if (fields.length > 0) {
|
||||
return fields
|
||||
.map((item) => `${fieldLabel(item.field!)}: ${describeFieldError(item)}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
return error.message || t("common.systemError");
|
||||
}
|
||||
@@ -23,73 +23,9 @@ export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export const calculateBytes = (value = 0, unit = "Bytes"): number => {
|
||||
// Приведение единицы к верхнему регистру и удаление пробелов
|
||||
const formattedUnit = unit.toUpperCase().trim();
|
||||
|
||||
// Маппинг единиц хранения на количество байт
|
||||
const unitToBytes: Record<string, number> = {
|
||||
BYTES: 1,
|
||||
KB: 1024 ** 1,
|
||||
MB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
PB: 1024 ** 5,
|
||||
EB: 1024 ** 6,
|
||||
ZB: 1024 ** 7,
|
||||
YB: 1024 ** 8,
|
||||
};
|
||||
|
||||
// Проверка наличия единицы в маппинге
|
||||
if (!Object.prototype.hasOwnProperty.call(unitToBytes, formattedUnit)) {
|
||||
throw new Error("Invalid unit");
|
||||
}
|
||||
|
||||
if (value == -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Расчёт и возврат количества байт
|
||||
return value * unitToBytes[formattedUnit];
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование единицы хранения
|
||||
* @param bytes Объём хранения в байтах
|
||||
* @param decimals Количество знаков после запятой, по умолчанию 2
|
||||
* @returns Отформатированное значение объёма хранения
|
||||
*/
|
||||
export const formatStorageCapacity = (bytes: number, decimals = 2): number => {
|
||||
// Проверка корректности входных данных
|
||||
if (!bytes || bytes <= 0) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Расчёт единицы хранения
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
// Форматирование и возврат объёма хранения
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование единицы хранения
|
||||
* @param bytes Объём хранения в байтах
|
||||
* @returns Отформатированная единица хранения
|
||||
*/
|
||||
export const formatStorageUnit = (bytes: number): string => {
|
||||
// Проверка корректности входных данных
|
||||
if (!bytes || bytes <= 0) {
|
||||
return "Bytes";
|
||||
}
|
||||
|
||||
// Расчёт единицы хранения
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
// Возврат отформатированной единицы хранения
|
||||
return sizes[i];
|
||||
};
|
||||
// calculateBytes, formatStorageCapacity и formatStorageUnit удалены вместе с
|
||||
// компонентом UnitSelect, который был их единственным потребителем.
|
||||
//
|
||||
// Сам UnitSelect не использовался ни одной страницей панели: квота
|
||||
// вводится обычным полем в байтах, а показывается через formatBytes. Компонент
|
||||
// остался от поколения, в котором форма пира предлагала выбор единиц.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
/**
|
||||
* Скачивание бинарного ответа API одним способом на всю панель.
|
||||
*
|
||||
* Раньше эта последовательность — Blob, createObjectURL, временная ссылка,
|
||||
* клик, revokeObjectURL — была скопирована в трёх местах, и все три копии
|
||||
* успели разойтись: страница пиров разбирала Content-Disposition с запасным
|
||||
* именем файла, а обе страницы логов падали на `undefined.split(...)`, если
|
||||
* заголовка не оказалось.
|
||||
*
|
||||
* Хуже расхождения был порядок на страницах логов:
|
||||
*
|
||||
* const response = await exportLogApi(...); // <- запрос ВНЕ try
|
||||
* try { ... } catch (e) { } // <- и пустой catch
|
||||
*
|
||||
* Сетевой запрос стоял ПЕРЕД try, поэтому его отказ этим catch не ловился
|
||||
* вовсе, а всё, что происходило внутри, глушилось молча. Оператор нажимал
|
||||
* «Экспорт» и не получал ни файла, ни объяснения.
|
||||
*/
|
||||
|
||||
/** Имя файла по умолчанию, если сервер его не назвал. */
|
||||
const CONTENT_DISPOSITION_PREFIX = "attachment; filename=";
|
||||
|
||||
/**
|
||||
* Имя файла из заголовка ответа.
|
||||
*
|
||||
* Отсутствующий или незнакомый заголовок — не отказ: у скачивания есть
|
||||
* разумное запасное имя, и терять из-за формата заголовка уже полученный файл
|
||||
* незачем.
|
||||
*/
|
||||
export function fileNameFromResponse(
|
||||
response: AxiosResponse,
|
||||
fallback: string
|
||||
): string {
|
||||
const header = response.headers?.["content-disposition"];
|
||||
if (typeof header !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
const index = header.indexOf(CONTENT_DISPOSITION_PREFIX);
|
||||
if (index < 0) {
|
||||
return fallback;
|
||||
}
|
||||
const name = header.slice(index + CONTENT_DISPOSITION_PREFIX.length).trim();
|
||||
return name === "" ? fallback : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отдаёт бинарный ответ пользователю как файл.
|
||||
*
|
||||
* Временная ссылка УДАЛЯЕТСЯ из документа, а objectURL освобождается в
|
||||
* `finally`. Прежние копии оставляли `<a>` в body навсегда — по одному
|
||||
* элементу на каждое нажатие «Экспорт» за всю жизнь вкладки.
|
||||
*/
|
||||
export function saveResponseAsFile(
|
||||
response: AxiosResponse,
|
||||
fallbackFileName: string
|
||||
): void {
|
||||
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
|
||||
try {
|
||||
anchor.href = url;
|
||||
anchor.download = fileNameFromResponse(response, fallbackFileName);
|
||||
anchor.style.display = "none";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
} finally {
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { useAdminStoreHook } from "@/store/modules/admin";
|
||||
import i18n from "@/lang/index";
|
||||
import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error";
|
||||
import { describeApiError } from "@/utils/api-message";
|
||||
|
||||
const dynamicBase = (window as any).__dynamic_base__ || "";
|
||||
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
|
||||
@@ -10,6 +16,25 @@ const dynamicBase = (window as any).__dynamic_base__ || "";
|
||||
// ADMIN_API_BASE в оркестраторе.
|
||||
const API_BASE = "/api";
|
||||
const t = i18n.global.t;
|
||||
|
||||
/**
|
||||
* Запрос может отказаться от общего тоста, если показывает причину сам.
|
||||
*
|
||||
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
|
||||
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
|
||||
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
|
||||
* предупреждением, а не ошибкой.
|
||||
*
|
||||
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
|
||||
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
|
||||
* и молчание о нём означало бы операцию, не происходящую без объяснений.
|
||||
*/
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
skipErrorToast?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Создание axios instance
|
||||
const service = axios.create({
|
||||
baseURL: `${dynamicBase}${API_BASE}`,
|
||||
@@ -31,38 +56,110 @@ service.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Сессия кончилась под руками у оператора.
|
||||
*
|
||||
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
|
||||
* отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй
|
||||
* аргумент interceptors.response.use) для отказов API не вызывался вовсе — а
|
||||
* жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и
|
||||
* поле `msg`, которых в этом API никогда не было: остатки чужого шаблона.
|
||||
* Ключ common.sessionExpired существовал и был мёртвым.
|
||||
*
|
||||
* Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько
|
||||
* параллельных запросов страницы, и без этого оператор получил бы стопку
|
||||
* одинаковых окон.
|
||||
*/
|
||||
let sessionPromptOpen = false;
|
||||
|
||||
function promptSignIn(expired: boolean): void {
|
||||
if (sessionPromptOpen) {
|
||||
return;
|
||||
}
|
||||
sessionPromptOpen = true;
|
||||
|
||||
const finish = () => {
|
||||
sessionPromptOpen = false;
|
||||
// Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то
|
||||
// есть заодно стирал выбранный оператором язык панели: при следующем входе
|
||||
// интерфейс возвращался к значению по умолчанию без всякой причины.
|
||||
useAdminStoreHook().resetToken();
|
||||
const redirect = encodeURIComponent(
|
||||
window.location.pathname + window.location.search
|
||||
);
|
||||
window.location.href = `/login?redirect=${redirect}`;
|
||||
};
|
||||
|
||||
ElMessageBox.confirm(
|
||||
expired ? t("common.sessionExpired") : t("common.signInRequired"),
|
||||
t("common.warning"),
|
||||
{
|
||||
confirmButtonText: t("common.signIn"),
|
||||
showCancelButton: false,
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false,
|
||||
showClose: false,
|
||||
type: "warning",
|
||||
}
|
||||
)
|
||||
.then(finish)
|
||||
.catch(finish);
|
||||
}
|
||||
|
||||
// Response interceptor
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const { code, message } = response.data;
|
||||
if (code === 20000) {
|
||||
return response.data;
|
||||
}
|
||||
// Обработка бинарного ответа при экспорте файлов
|
||||
if (response.data instanceof ArrayBuffer || response.data instanceof Blob) {
|
||||
// Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
|
||||
// проверяться ДО обращения к его полям: у Blob их нет.
|
||||
if (
|
||||
response.data instanceof ArrayBuffer ||
|
||||
response.data instanceof Blob
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
ElMessage.error(message || "Системная ошибка");
|
||||
return Promise.reject(new Error(message || "Error"));
|
||||
},
|
||||
(error: any) => {
|
||||
if (error.response.data) {
|
||||
const { code, msg } = error.response.data;
|
||||
// Token истёк, нужен повторный вход
|
||||
if (code === "A0230") {
|
||||
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), {
|
||||
confirmButtonText: t("common.confirm"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
localStorage.clear();
|
||||
window.location.href = "/";
|
||||
});
|
||||
} else {
|
||||
ElMessage.error(msg || "Системная ошибка");
|
||||
}
|
||||
const payload = response.data as ApiErrorPayload;
|
||||
if (payload?.code === API_CODE.success) {
|
||||
return response.data;
|
||||
}
|
||||
return Promise.reject(error.message);
|
||||
|
||||
const apiError = new ApiError(payload ?? { code: API_CODE.systemError });
|
||||
|
||||
if (apiError.requiresSignIn) {
|
||||
promptSignIn(apiError.sessionExpired);
|
||||
return Promise.reject(apiError);
|
||||
}
|
||||
|
||||
if (!response.config?.skipErrorToast) {
|
||||
ElMessage.error(describeApiError(apiError));
|
||||
}
|
||||
return Promise.reject(apiError);
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
|
||||
// HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
|
||||
// самого error.response — то есть при обрыве соединения падал с
|
||||
// TypeError и подменял настоящую причину отказом внутри обработчика.
|
||||
const message = error.response
|
||||
? t("common.systemError")
|
||||
: t("common.networkError");
|
||||
|
||||
// skipErrorToast здесь НЕ действует, и это существенно.
|
||||
//
|
||||
// Флаг существует ради одного случая: страница разбирает причину отказа
|
||||
// сама — раскладывает её по полям формы или отличает частичный результат
|
||||
// от полного отказа по коду. Всё это возможно только для отказа API, у
|
||||
// которого есть конверт с `code` и `errors`.
|
||||
//
|
||||
// У транспортного отказа конверта нет вовсе. Страница о нём не может
|
||||
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
|
||||
// своих обработчиках `// транспортный отказ уже показан общим
|
||||
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
|
||||
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
|
||||
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
|
||||
// молча.
|
||||
ElMessage.error(message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { adminChangePasswordApi } from "@/api/admin";
|
||||
import { useAdminStore } from "@/store/modules/admin";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
adminPasswordFormRule,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -46,7 +52,33 @@ const form = reactive({
|
||||
newPassword: "",
|
||||
});
|
||||
|
||||
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
|
||||
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
|
||||
//
|
||||
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
|
||||
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
|
||||
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
|
||||
// фразой «Некорректное значение», не называя ни одного требования.
|
||||
//
|
||||
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
|
||||
// пароль, назначенный здесь, невозможно было ввести там.
|
||||
//
|
||||
// Правило — собственное, а не встроенные `min`/`max` Element Plus: те
|
||||
// делегируются `async-validator`, который считает длину строки в единицах
|
||||
// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в
|
||||
// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и
|
||||
// возвращался с сервера системной ошибкой хеширования.
|
||||
//
|
||||
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
|
||||
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
|
||||
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
|
||||
const passwordRule = adminPasswordFormRule(
|
||||
t("credentials.passwordFormat", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
|
||||
})
|
||||
);
|
||||
|
||||
const rules: FormRules = {
|
||||
oldPassword: [
|
||||
{
|
||||
@@ -54,11 +86,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
newPassword: [
|
||||
{
|
||||
@@ -66,11 +94,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,37 @@
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<!--
|
||||
Два независимых факта показываются отдельно и называются своими именами.
|
||||
Раньше их было полтора: доступность Traffic Stats API выводилась из
|
||||
ответа systemctl, поэтому дашборд умел утверждать «служба остановлена» и
|
||||
«API доступен» одновременно.
|
||||
-->
|
||||
<el-card shadow="never" class="mt-2">
|
||||
<el-descriptions :column="3" size="small" border>
|
||||
<el-descriptions-item :label="$t('hysteria.hysteria2Version')">
|
||||
{{ summary.hysteria.version || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('dashboard.serviceStateLabel')">
|
||||
<el-tag size="small" :type="serviceStateTagType">
|
||||
{{ $t(`dashboard.serviceState.${summary.hysteria.serviceState}`) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('dashboard.trafficApiLabel')">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="summary.hysteria.apiReachable ? 'success' : 'danger'"
|
||||
>
|
||||
{{
|
||||
summary.hysteria.apiReachable
|
||||
? $t("dashboard.trafficApiReachable")
|
||||
: $t("dashboard.trafficApiUnreachable")
|
||||
}}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="10" class="mt-2">
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
@@ -58,16 +89,24 @@
|
||||
>{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card
|
||||
></el-col
|
||||
>
|
||||
<!--
|
||||
Пока Traffic Stats API недоступен, «0 онлайн» — не данные, а
|
||||
утверждение, которого никто не проверял. Раньше именно так и было:
|
||||
сборщик метрик получал пустую картину подключений из ответа systemctl и
|
||||
показывал ноль рядом с плашкой «служба остановлена».
|
||||
-->
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
>{{ $t("dashboard.onlinePeers") }}:
|
||||
{{ summary.peers.onlinePeers }}</el-card
|
||||
{{ summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?" }}</el-card
|
||||
></el-col
|
||||
>
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
><el-card shadow="never"
|
||||
>{{ $t("dashboard.onlineDevices") }}:
|
||||
{{ summary.peers.onlineDevices }}</el-card
|
||||
{{
|
||||
summary.hysteria.apiReachable ? summary.peers.onlineDevices : "?"
|
||||
}}</el-card
|
||||
></el-col
|
||||
>
|
||||
<el-col :xs="24" :sm="12" :lg="6"
|
||||
@@ -183,7 +222,14 @@ const summary = ref<DashboardSummaryVo>({
|
||||
diskTotalBytes: 0,
|
||||
diskPercent: 0,
|
||||
},
|
||||
hysteria: { version: "-", running: false, apiReachable: false },
|
||||
// До первого ответа сервера о службе не известно ничего: `inactive` здесь
|
||||
// было бы утверждением, которого никто не проверял.
|
||||
hysteria: {
|
||||
version: "-",
|
||||
serviceState: "unknown",
|
||||
running: false,
|
||||
apiReachable: false,
|
||||
},
|
||||
peers: {
|
||||
total: 0,
|
||||
enabled: 0,
|
||||
@@ -231,6 +277,23 @@ const isStale = computed(() => {
|
||||
return Date.now() - lastSuccessAt.value > staleThresholdMs;
|
||||
});
|
||||
|
||||
/**
|
||||
* Цвет плашки состояния службы.
|
||||
*
|
||||
* «Неизвестно» — предупреждение, а не ошибка: это отказ ДИАГНОСТИКИ, и он не
|
||||
* означает, что туннель не работает.
|
||||
*/
|
||||
const serviceStateTagType = computed(() => {
|
||||
switch (summary.value.hysteria.serviceState) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "inactive":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
});
|
||||
|
||||
const loadDashboard = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag
|
||||
v-for="item in outbounds"
|
||||
:key="item.name"
|
||||
@click="handleInfo(item)"
|
||||
size="large"
|
||||
>
|
||||
{{ item.name }}
|
||||
</el-tag>
|
||||
|
||||
<el-text v-if="!outbounds.length" type="info">
|
||||
{{ $t("hysteria.outboundsEmpty") }}
|
||||
</el-text>
|
||||
|
||||
<el-dialog
|
||||
:title="outboundInfoDialog.title"
|
||||
v-model="outboundInfoDialog.visible"
|
||||
width="600px"
|
||||
append-to-body
|
||||
@close="outboundInfoDialog.visible = false"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.name')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="name" prop="name">
|
||||
<el-tag>{{ outboundInfo.name }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.type')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="type" prop="type">
|
||||
<el-tag>{{ outboundInfo.type }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<template v-if="outboundInfo.type === 'socks5'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.addr')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.addr" prop="socks5.addr">
|
||||
<el-tag>{{ outboundInfo.socks5.addr }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.username')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.username" prop="socks5.username">
|
||||
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.socks5.password')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="socks5.password" prop="socks5.password">
|
||||
<el-tag>{{ outboundInfo.socks5.password }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="outboundInfo.type === 'http'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.http.url')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="http.url" prop="http.url">
|
||||
<el-tag>{{ outboundInfo.http.url }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.http.insecure')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="http.insecure" prop="http.insecure">
|
||||
<el-tag>{{ outboundInfo.http.insecure }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="outboundInfo.type === 'direct'">
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.mode')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.mode" prop="direct.mode">
|
||||
<el-tag>{{ outboundInfo.direct.mode }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindIPv4')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindIPv4" prop="direct.bindIPv4">
|
||||
<el-tag>{{ outboundInfo.direct.bindIPv4 }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindIPv6')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindIPv6" prop="direct.bindIPv6">
|
||||
<el-tag>{{ outboundInfo.direct.bindIPv6 }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.bindDevice')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.bindDevice" prop="direct.bindDevice">
|
||||
<el-tag>{{ outboundInfo.direct.bindDevice }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="$t('hysteria.config.outbounds.direct.fastOpen')"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-form-item label="direct.fastOpen" prop="direct.fastOpen">
|
||||
<el-tag>{{ outboundInfo.direct.fastOpen }}</el-tag>
|
||||
</el-form-item>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="outboundInfoDialog.visible = false"
|
||||
>{{ $t("common.close") }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "outbounds",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Hysteria2ServerConfigOutbound } from "@/api/config/types";
|
||||
import {
|
||||
defaultHysteria2ServerConfigOutboundView,
|
||||
normalizeOutboundViewModel,
|
||||
type Hysteria2ServerConfigOutboundView,
|
||||
} from "@/api/config/hysteriaViewModel";
|
||||
import { PropType } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Компонент только показывает outbounds, и это не сужение возможностей, а
|
||||
// приведение UI в соответствие с тем, что продукт умеет.
|
||||
//
|
||||
// Что было. Здесь жил полноценный редактор: кнопка «+», диалог создания,
|
||||
// submitForm с валидацией, closable-теги с удалением и `emit("update:outbounds")`.
|
||||
// Ни одно из этих действий не могло ничего сохранить:
|
||||
//
|
||||
// родительская форма отрисована с `:disabled="true"` — страница read-only и
|
||||
// прямо сообщает, что конфигом Hysteria владеет `hy2xs-orchestrator reconfigure`;
|
||||
//
|
||||
// родитель передаёт `:outbounds="dataForm.outbounds"` БЕЗ v-model, то есть
|
||||
// слушателя у `update:outbounds` не существует;
|
||||
//
|
||||
// маршрутов записи серверного конфига в API нет — они удалены вместе с
|
||||
// мёртвым updater/config-write слоем.
|
||||
//
|
||||
// То есть оператор мог добавить outbound, увидеть его в списке и уйти в
|
||||
// уверенности, что изменил конфигурацию сервера. Изменения не переживали даже
|
||||
// перехода на другую вкладку.
|
||||
const props = defineProps({
|
||||
outbounds: {
|
||||
required: true,
|
||||
type: Array as PropType<Array<Hysteria2ServerConfigOutbound>>,
|
||||
default: (): Array<Hysteria2ServerConfigOutbound> => [],
|
||||
},
|
||||
});
|
||||
|
||||
const outbounds = computed(() => props.outbounds ?? []);
|
||||
|
||||
const state = reactive({
|
||||
outboundInfoDialog: {
|
||||
title: t("hysteria.outbounds"),
|
||||
visible: false,
|
||||
},
|
||||
// Карточка работает с нормализованной моделью по той же причине, что и
|
||||
// основная форма: подблоки socks5/http/direct в схеме необязательны, а
|
||||
// шаблон обращается к их полям напрямую.
|
||||
outboundInfo: {
|
||||
...defaultHysteria2ServerConfigOutboundView,
|
||||
} as Hysteria2ServerConfigOutboundView,
|
||||
});
|
||||
|
||||
const { outboundInfoDialog, outboundInfo } = toRefs(state);
|
||||
|
||||
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
|
||||
state.outboundInfo = normalizeOutboundViewModel(outbound);
|
||||
state.outboundInfoDialog.visible = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex.gap-2 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,109 +1,24 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="setRecords"
|
||||
>
|
||||
<el-option label="100" value="100" />
|
||||
<el-option label="200" value="200" />
|
||||
<el-option label="300" value="300" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="setRecords">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column
|
||||
key="level"
|
||||
label="level"
|
||||
align="center"
|
||||
prop="level"
|
||||
/>
|
||||
<el-table-column key="msg" label="msg" align="center" prop="msg" />
|
||||
<el-table-column key="time" label="time" align="center" prop="time" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
<log-viewer
|
||||
:fetch-logs="logHysteria2Api"
|
||||
:export-option="1"
|
||||
export-file-name="hysteria2.log"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "index",
|
||||
name: "LogHysteria",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogDto, LogHysteria2Vo } from "@/api/log/types";
|
||||
import { exportLogApi, logHysteria2Api } from "@/api/log";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
total: 0,
|
||||
records: [] as LogHysteria2Vo[],
|
||||
queryParams: {
|
||||
numLine: 100,
|
||||
} as LogDto,
|
||||
});
|
||||
|
||||
const { loading, records, queryParams } = toRefs(state);
|
||||
|
||||
const setRecords = async () => {
|
||||
try {
|
||||
state.loading = true;
|
||||
const { data } = await logHysteria2Api(state.queryParams);
|
||||
state.records = data.records;
|
||||
state.total = data.total;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
let response = await exportLogApi({ option: 1 });
|
||||
try {
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
a.href = url;
|
||||
let dis = response.headers["content-disposition"];
|
||||
a.download = dis.split("attachment; filename=")[1];
|
||||
// Имитация клика для скачивания
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setRecords();
|
||||
});
|
||||
/**
|
||||
* Журнал Hysteria.
|
||||
*
|
||||
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer — см.
|
||||
* страницу системного журнала.
|
||||
*/
|
||||
import LogViewer from "@/components/LogViewer/index.vue";
|
||||
import { logHysteria2Api } from "@/api/log";
|
||||
</script>
|
||||
|
||||
@@ -1,109 +1,25 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-toolbar">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
<el-form-item :label="$t('log.numLine')" prop="numLine">
|
||||
<el-select
|
||||
v-model="queryParams.numLine"
|
||||
style="width: 200px"
|
||||
@change="setRecords"
|
||||
>
|
||||
<el-option label="100" value="100" />
|
||||
<el-option label="200" value="200" />
|
||||
<el-option label="300" value="300" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="export">
|
||||
<el-button @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item prop="refresh">
|
||||
<el-button @click="setRecords">
|
||||
<template #icon>
|
||||
<i-ep-refresh />
|
||||
</template>
|
||||
{{ $t("common.refresh") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column
|
||||
key="level"
|
||||
label="level"
|
||||
align="center"
|
||||
prop="level"
|
||||
/>
|
||||
<el-table-column key="msg" label="msg" align="center" prop="msg" />
|
||||
<el-table-column key="time" label="time" align="center" prop="time" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
<log-viewer
|
||||
:fetch-logs="logSystemApi"
|
||||
:export-option="0"
|
||||
export-file-name="hy2xs-admin.log"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: "index",
|
||||
name: "LogSystem",
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogDto, LogSystemVo } from "@/api/log/types";
|
||||
import { exportLogApi, logSystemApi } from "@/api/log";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
total: 0,
|
||||
records: [] as LogSystemVo[],
|
||||
queryParams: {
|
||||
numLine: 100,
|
||||
} as LogDto,
|
||||
});
|
||||
|
||||
const { loading, records, queryParams } = toRefs(state);
|
||||
|
||||
const setRecords = async () => {
|
||||
try {
|
||||
state.loading = true;
|
||||
const { data } = await logSystemApi(state.queryParams);
|
||||
state.records = data.records;
|
||||
state.total = data.total;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
let response = await exportLogApi({ option: 0 });
|
||||
try {
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
a.href = url;
|
||||
let dis = response.headers["content-disposition"];
|
||||
a.download = dis.split("attachment; filename=")[1];
|
||||
// Имитация клика для скачивания
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setRecords();
|
||||
});
|
||||
/**
|
||||
* Журнал админки.
|
||||
*
|
||||
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer: страница
|
||||
* системного журнала и страница журнала Hysteria были побайтово одинаковы и
|
||||
* несли одни и те же дефекты в двух экземплярах.
|
||||
*/
|
||||
import LogViewer from "@/components/LogViewer/index.vue";
|
||||
import { logSystemApi } from "@/api/log";
|
||||
</script>
|
||||
|
||||
@@ -83,6 +83,16 @@ import { useAdminStore } from "@/store/modules/admin";
|
||||
// Зависимость API
|
||||
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
||||
import { AdminLoginDto } from "@/api/admin/types";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
ADMIN_USERNAME_CHARSET,
|
||||
ADMIN_USERNAME_MAX_LENGTH,
|
||||
ADMIN_USERNAME_MIN_LENGTH,
|
||||
ADMIN_USERNAME_PATTERN,
|
||||
adminPasswordFormRule,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const route = useRoute();
|
||||
@@ -114,6 +124,21 @@ const loginForm = ref<AdminLoginDto>({
|
||||
pass: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
|
||||
*
|
||||
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
|
||||
* символов, из-за чего форма входа отказывалась отправлять пароль, который
|
||||
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
|
||||
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
|
||||
*
|
||||
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
|
||||
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
|
||||
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
|
||||
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
|
||||
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
|
||||
* отказывал. См. isValidAdminPassword.
|
||||
*/
|
||||
const loginRules = {
|
||||
username: [
|
||||
{
|
||||
@@ -122,8 +147,12 @@ const loginRules = {
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: t("login.usernameFormatIncorrect"),
|
||||
pattern: ADMIN_USERNAME_PATTERN,
|
||||
message: t("credentials.usernameFormat", {
|
||||
min: ADMIN_USERNAME_MIN_LENGTH,
|
||||
max: ADMIN_USERNAME_MAX_LENGTH,
|
||||
charset: ADMIN_USERNAME_CHARSET,
|
||||
}),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
@@ -133,11 +162,13 @@ const loginRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: t("login.passwordFormatIncorrect"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
adminPasswordFormRule(
|
||||
t("credentials.passwordFormat", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
|
||||
})
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -205,10 +236,61 @@ const handleLogin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Видимое поле формы входа — это `el-form-item`, а не `el-input`.
|
||||
//
|
||||
// Рамка и фон нарисованы здесь, потому что внутрь одного поля кладутся три
|
||||
// вещи: иконка, ввод и переключатель видимости пароля. `el-input` занимает лишь
|
||||
// среднюю из них.
|
||||
//
|
||||
// Отсюда и дефект индикации ошибки, который был виден на форме. Element Plus
|
||||
// рисует состояние отказа на `el-input__wrapper` правилом
|
||||
//
|
||||
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
|
||||
//
|
||||
// то есть селектором из ЧЕТЫРЁХ классов, а здешнее гашение тени записывалось
|
||||
// селектором из трёх — и проигрывало по специфичности. В результате красная
|
||||
// рамка ложилась вокруг одного лишь поля ввода: у логина она начиналась после
|
||||
// иконки пользователя, у пароля обрывалась перед «глазом», и ни одна её сторона
|
||||
// не совпадала с видимой границей поля.
|
||||
//
|
||||
// Чинится это не увеличением специфичности ради победы, а переносом индикации
|
||||
// на тот элемент, который оператор и видит полем.
|
||||
.el-form-item {
|
||||
background: var(--subMenuBg);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 5px;
|
||||
|
||||
// Просвет под полем принадлежит сообщению об отказе: `el-form-item__error`
|
||||
// позиционируется абсолютно от `top: 100%`, то есть живёт ВНЕ рамки. При
|
||||
// стандартных 18px оно вплотную прижималось к границе снизу и к следующему
|
||||
// полю сверху.
|
||||
margin-bottom: 26px;
|
||||
|
||||
&.is-error {
|
||||
border-color: var(--el-color-danger);
|
||||
|
||||
// Штатная индикация Element Plus гасится ЗДЕСЬ, а не в блоке `.el-input`:
|
||||
// селектор повторяет её собственный и добавляет атрибут scoped-стиля,
|
||||
// поэтому выигрывает по специфичности. Прежнее гашение стояло на два
|
||||
// класса ниже и проигрывало — из-за чего красный прямоугольник вокруг
|
||||
// одного лишь поля ввода и появлялся. `!important` здесь не нужен: правило
|
||||
// не сильнее чужого, а конкретнее.
|
||||
:deep(.el-form-item__content .el-input__wrapper) {
|
||||
&,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.is-focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сообщение выравнивается по тексту поля, а не по краю рамки: иначе оно
|
||||
// висит на сдвиг левее всего, что находится над ним.
|
||||
:deep(.el-form-item__error) {
|
||||
padding-top: 6px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input {
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
>
|
||||
<el-button>{{ $t("common.import") }}</el-button>
|
||||
</el-upload>
|
||||
<el-tooltip :content="$t('peer.exportSettingsHint')" placement="bottom">
|
||||
<!--
|
||||
Ширина всплывающей подсказки ограничена штатным popper-style, а не
|
||||
глобальным CSS. Без ограничения popper берёт естественную ширину
|
||||
содержимого, и длинная подсказка растягивалась почти на весь экран
|
||||
одной строкой.
|
||||
-->
|
||||
<el-tooltip
|
||||
:content="$t('peer.exportSettingsHint')"
|
||||
placement="bottom"
|
||||
:popper-style="TOOLTIP_POPPER_STYLE"
|
||||
>
|
||||
<el-button class="ml-2" @click="handleExport">{{
|
||||
$t("peer.exportSettings")
|
||||
}}</el-button>
|
||||
@@ -44,6 +54,20 @@
|
||||
{{ $t("peer.exportBackup") }}
|
||||
</el-button>
|
||||
</div>
|
||||
<!--
|
||||
Недоступность Traffic Stats API называется прямо, а не изображается
|
||||
строками «офлайн». Это разные ответы: первый указывает на control
|
||||
plane, второй отправляет оператора искать проблему у пользователей.
|
||||
-->
|
||||
<el-alert
|
||||
v-if="onlineUnknown"
|
||||
:title="$t('peer.onlineUnavailable')"
|
||||
:description="$t('peer.onlineUnavailableHint')"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
/>
|
||||
<el-table v-loading="loading" :data="records">
|
||||
<el-table-column :label="$t('peer.name')" min-width="220">
|
||||
<template #default="scope">
|
||||
@@ -63,6 +87,14 @@
|
||||
}}</el-tag
|
||||
>
|
||||
<el-tag
|
||||
v-if="onlineUnknown"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
type="warning"
|
||||
>{{ $t("peer.onlineUnknown") }}</el-tag
|
||||
>
|
||||
<el-tag
|
||||
v-else
|
||||
size="small"
|
||||
class="ml-1"
|
||||
:type="scope.row.online ? 'success' : 'info'"
|
||||
@@ -92,8 +124,12 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('peer.devices')" min-width="120">
|
||||
<!--
|
||||
Число подключённых устройств известно только из Traffic Stats API.
|
||||
Пока он молчит, «0» было бы утверждением, а не данными.
|
||||
-->
|
||||
<template #default="scope"
|
||||
>{{ scope.row.onlineDevices }} /
|
||||
>{{ onlineUnknown ? "?" : scope.row.onlineDevices }} /
|
||||
{{ scope.row.maxDevices }}</template
|
||||
>
|
||||
</el-table-column>
|
||||
@@ -132,35 +168,53 @@
|
||||
@click="copyUri(peerRow(scope.row))"
|
||||
>{{ $t("peer.copyUri") }}</el-button
|
||||
>
|
||||
<el-dropdown trigger="click" placement="bottom-end">
|
||||
<!--
|
||||
Действия объявлены через command, а не обработчиками click на
|
||||
каждом пункте. Это штатный API el-dropdown для меню действий:
|
||||
команда приходит в одно место, и добавить пункт, забыв его
|
||||
подключить, здесь уже нельзя.
|
||||
-->
|
||||
<el-dropdown
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
@command="
|
||||
(command: PeerCommand) =>
|
||||
runCommand(command, peerRow(scope.row))
|
||||
"
|
||||
>
|
||||
<el-button text circle class="peer-more-btn" @click.stop>
|
||||
<i-ep-more-filled />
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="showQr(peerRow(scope.row))">{{
|
||||
<el-dropdown-item command="qr">{{
|
||||
$t("peer.clientQr")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="handleUpdate(peerRow(scope.row))"
|
||||
>{{ $t("common.edit") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item
|
||||
@click="handleResetTraffic(peerRow(scope.row))"
|
||||
>{{ $t("common.resetTraffic") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item @click="handleKick(peerRow(scope.row))">{{
|
||||
<el-dropdown-item command="edit">{{
|
||||
$t("common.edit")
|
||||
}}</el-dropdown-item>
|
||||
<!--
|
||||
Быстрое включение/отключение. Пункта здесь не было вовсе:
|
||||
сменить состояние можно было только через форму
|
||||
изменения, хотя это самое частое действие оператора.
|
||||
-->
|
||||
<el-dropdown-item command="toggle-disabled">{{
|
||||
scope.row.disabled === 1
|
||||
? $t("peer.enablePeer")
|
||||
: $t("peer.disablePeer")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item command="reset-traffic">{{
|
||||
$t("common.resetTraffic")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item divided command="kick">{{
|
||||
$t("peer.kick")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="handleReleaseKick(peerRow(scope.row))"
|
||||
>{{ $t("peer.releaseKick") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item
|
||||
divided
|
||||
@click="handleDelete(peerRow(scope.row))"
|
||||
>{{ $t("common.delete") }}</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item command="release-kick">{{
|
||||
$t("peer.releaseKick")
|
||||
}}</el-dropdown-item>
|
||||
<el-dropdown-item divided command="delete">{{
|
||||
$t("common.delete")
|
||||
}}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -184,21 +238,29 @@
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
>
|
||||
<el-form-item :label="$t('peer.name')" prop="name">
|
||||
<el-form-item
|
||||
:label="$t('peer.name')"
|
||||
prop="name"
|
||||
:error="serverErrors.name"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.name"
|
||||
:placeholder="$t('peer.form.namePlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.remark')">
|
||||
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
|
||||
<el-input
|
||||
v-model="dataForm.remark"
|
||||
:placeholder="$t('peer.form.remarkPlaceholder')"
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.secret')" prop="secret">
|
||||
<el-form-item
|
||||
:label="$t('peer.secret')"
|
||||
prop="secret"
|
||||
:error="serverErrors.secret"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.secret"
|
||||
show-password
|
||||
@@ -206,20 +268,29 @@
|
||||
/>
|
||||
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.quota')">
|
||||
<el-form-item
|
||||
:label="$t('peer.quota')"
|
||||
:error="serverErrors.quotaBytes"
|
||||
>
|
||||
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
|
||||
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.expireTime')"
|
||||
<el-form-item
|
||||
:label="$t('peer.expireTime')"
|
||||
:error="serverErrors.expiresAt"
|
||||
><el-date-picker
|
||||
v-model="dataForm.expiresAt"
|
||||
type="datetime"
|
||||
value-format="x"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.maxDevices')"
|
||||
<el-form-item
|
||||
:label="$t('peer.maxDevices')"
|
||||
:error="serverErrors.maxDevices"
|
||||
><el-input-number v-model="dataForm.maxDevices" :min="1"
|
||||
/></el-form-item>
|
||||
<el-form-item :label="$t('peer.disabled')"
|
||||
<el-form-item
|
||||
:label="$t('peer.disabled')"
|
||||
:error="serverErrors.disabled"
|
||||
><el-switch v-model="disabledBool"
|
||||
/></el-form-item>
|
||||
</el-form>
|
||||
@@ -293,7 +364,8 @@
|
||||
{{ formatBytes(overview.data.uploadBytes) }}
|
||||
</p>
|
||||
<p>
|
||||
<b>{{ $t("peer.devices") }}:</b> {{ overview.data.onlineDevices }} /
|
||||
<b>{{ $t("peer.devices") }}:</b>
|
||||
{{ onlineUnknown ? "?" : overview.data.onlineDevices }} /
|
||||
{{ overview.data.maxDevices }}
|
||||
</p>
|
||||
<p>
|
||||
@@ -342,7 +414,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import QrcodeVue from "qrcode.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import copy from "copy-to-clipboard";
|
||||
@@ -363,12 +435,41 @@ import {
|
||||
exportPeerApi,
|
||||
} from "@/api/peer";
|
||||
import {
|
||||
PeerOnlineState,
|
||||
PeerPageDto,
|
||||
PeerSaveDto,
|
||||
PeerUpdateDto,
|
||||
PeerVo,
|
||||
} from "@/api/peer/types";
|
||||
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
import { ERR_CODE, isApiError } from "@/utils/api-error";
|
||||
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
|
||||
import { saveResponseAsFile } from "@/utils/download";
|
||||
|
||||
/**
|
||||
* Ограничение ширины всплывающих подсказок.
|
||||
*
|
||||
* Объявлено значением, а не литералом в шаблоне: подсказок в панели больше
|
||||
* одной, и «широкая» подсказка — это свойство панели, а не отдельной кнопки.
|
||||
*/
|
||||
const TOOLTIP_POPPER_STYLE = {
|
||||
maxWidth: "360px",
|
||||
whiteSpace: "normal",
|
||||
lineHeight: "1.5",
|
||||
} as const;
|
||||
|
||||
/** Срок временной блокировки по умолчанию — один час. */
|
||||
const KICK_DURATION_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Действия меню строки пира. */
|
||||
type PeerCommand =
|
||||
| "qr"
|
||||
| "edit"
|
||||
| "toggle-disabled"
|
||||
| "reset-traffic"
|
||||
| "kick"
|
||||
| "release-kick"
|
||||
| "delete";
|
||||
|
||||
/**
|
||||
* Единственный переход от строки слота таблицы к модели пира.
|
||||
@@ -396,6 +497,14 @@ const { t } = useI18n();
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
const records = ref<PeerVo[]>([]);
|
||||
/**
|
||||
* Известна ли картина подключений.
|
||||
*
|
||||
* До первого ответа считается известной: пустая таблица и без того ничего не
|
||||
* утверждает, а предупреждение на пустом экране только сбивало бы.
|
||||
*/
|
||||
const onlineState = ref<PeerOnlineState>("ok");
|
||||
const onlineUnknown = computed(() => onlineState.value === "unavailable");
|
||||
const qrDialog = ref(false);
|
||||
const qrUrl = ref("");
|
||||
const importFileList = ref<UploadFile[]>([]);
|
||||
@@ -430,6 +539,56 @@ const disabledBool = computed({
|
||||
set: (v: boolean) => (dataForm.disabled = v ? 1 : 0),
|
||||
});
|
||||
|
||||
/**
|
||||
* Причины отказа, присланные сервером, — по именам полей формы.
|
||||
*
|
||||
* Сервер остаётся ЕДИНСТВЕННЫМ авторитетом: правила ниже лишь избавляют
|
||||
* оператора от лишнего похода на сервер за очевидной ошибкой, а окончательный
|
||||
* ответ всегда даёт он. Поэтому его причины подставляются прямо под поля, а не
|
||||
* показываются тостом «Invalid», как было раньше.
|
||||
*/
|
||||
const serverErrors = reactive<Record<string, string>>({});
|
||||
|
||||
function clearServerErrors() {
|
||||
for (const key of Object.keys(serverErrors)) {
|
||||
delete serverErrors[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Правка поля снимает серверную причину с НЕГО.
|
||||
*
|
||||
* Проп `error` у `el-form-item` перекрывает внутреннее состояние проверки:
|
||||
* оставленная под полем серверная причина висела бы там, пока оператор
|
||||
* исправляет значение, и не исчезала бы даже когда локальные правила уже
|
||||
* довольны. Снимается причина только с изменённого поля — остальные отказы
|
||||
* той же отправки всё ещё в силе, и убирать их означало бы скрыть работу,
|
||||
* которую оператору ещё предстоит сделать.
|
||||
*/
|
||||
watch(
|
||||
() => ({ ...dataForm }),
|
||||
(next, previous) => {
|
||||
if (!previous) {
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(serverErrors)) {
|
||||
if (
|
||||
next[key as keyof typeof next] !==
|
||||
previous[key as keyof typeof previous]
|
||||
) {
|
||||
delete serverErrors[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Зеркало серверного контракта, а не второй его экземпляр: границы и набор
|
||||
// символов заданы в service.IsValidPeerName и dto.PeerSaveDto, и расхождение
|
||||
// здесь приводит лишь к лишнему запросу, а не к принятому некорректному пиру.
|
||||
const PEER_NAME_PATTERN = /^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$/;
|
||||
const SECRET_MIN_LENGTH = 6;
|
||||
const SECRET_MAX_LENGTH = 128;
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{
|
||||
@@ -437,6 +596,57 @@ const rules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: PEER_NAME_PATTERN,
|
||||
message: t("error.code.peer_name", {
|
||||
field: t("error.field.name"),
|
||||
min: 6,
|
||||
max: 32,
|
||||
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
|
||||
}),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
secret: [
|
||||
{
|
||||
// Пустое поле — законный ввод: секрет сгенерирует сервер. Проверяется
|
||||
// только НЕПУСТОЕ значение.
|
||||
validator: (
|
||||
_rule: unknown,
|
||||
value: string,
|
||||
callback: (error?: Error) => void
|
||||
) => {
|
||||
const manual = (value ?? "").trim();
|
||||
if (manual === "") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (manual.length < SECRET_MIN_LENGTH) {
|
||||
callback(
|
||||
new Error(
|
||||
t("error.code.min_length", {
|
||||
field: t("error.field.secret"),
|
||||
min: SECRET_MIN_LENGTH,
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (manual.length > SECRET_MAX_LENGTH) {
|
||||
callback(
|
||||
new Error(
|
||||
t("error.code.max_length", {
|
||||
field: t("error.field.secret"),
|
||||
max: SECRET_MAX_LENGTH,
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -475,12 +685,17 @@ async function handleQuery() {
|
||||
const { data } = await pagePeerApi(queryParams);
|
||||
records.value = data.records;
|
||||
total.value = data.total;
|
||||
// Признак приходит один на страницу: недоступность Traffic Stats API —
|
||||
// свойство ответа целиком. При `unavailable` поле `online` в строках не
|
||||
// означает ничего, и показывать его как «офлайн» нельзя.
|
||||
onlineState.value = data.onlineState;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
clearServerErrors();
|
||||
Object.assign(dataForm, {
|
||||
id: undefined,
|
||||
name: "",
|
||||
@@ -497,6 +712,7 @@ function handleAdd() {
|
||||
}
|
||||
|
||||
async function handleUpdate(row: PeerVo) {
|
||||
clearServerErrors();
|
||||
const { data } = await getPeerApi({ id: row.id });
|
||||
Object.assign(dataForm, data, { secret: "" });
|
||||
dialog.title = t("common.update");
|
||||
@@ -505,69 +721,254 @@ async function handleUpdate(row: PeerVo) {
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
clearServerErrors();
|
||||
|
||||
if (formRef.value) {
|
||||
const ok = await formRef.value.validate().catch(() => false);
|
||||
if (!ok) return;
|
||||
}
|
||||
if (dialog.editId > 0 && dataForm.secret) {
|
||||
await ElMessageBox.confirm(
|
||||
t("peer.secretRotateConfirm"),
|
||||
t("common.warning"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
// Отмена подтверждения — ответ оператора, а не ошибка: форма остаётся
|
||||
// открытой с введённым, и в консоль ничего не улетает.
|
||||
if (
|
||||
dialog.editId > 0 &&
|
||||
dataForm.secret &&
|
||||
!(await confirmAction(t("peer.secretRotateConfirm")))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (dialog.editId > 0) {
|
||||
const payload: PeerUpdateDto = {
|
||||
id: dialog.editId,
|
||||
name: dataForm.name,
|
||||
secret: dataForm.secret || undefined,
|
||||
quotaBytes: dataForm.quotaBytes,
|
||||
expiresAt: dataForm.expiresAt,
|
||||
maxDevices: dataForm.maxDevices,
|
||||
disabled: dataForm.disabled,
|
||||
remark: dataForm.remark,
|
||||
};
|
||||
await updatePeerApi(payload);
|
||||
} else {
|
||||
await savePeerApi(dataForm);
|
||||
|
||||
try {
|
||||
if (dialog.editId > 0) {
|
||||
const payload: PeerUpdateDto = {
|
||||
id: dialog.editId,
|
||||
name: dataForm.name,
|
||||
// Пустой секрет при изменении означает «не менять», и сервер читает
|
||||
// его именно так. Отправлять undefined больше не требуется, но и вреда
|
||||
// в этом нет: оба состояния для него теперь одинаковы.
|
||||
secret: dataForm.secret || undefined,
|
||||
quotaBytes: dataForm.quotaBytes,
|
||||
expiresAt: dataForm.expiresAt,
|
||||
maxDevices: dataForm.maxDevices,
|
||||
disabled: dataForm.disabled,
|
||||
remark: dataForm.remark,
|
||||
};
|
||||
await updatePeerApi(payload);
|
||||
} else {
|
||||
// Секрет отправляется как есть, включая пустую строку: автогенерация —
|
||||
// обязанность сервера, а не подстановка значения здесь.
|
||||
await savePeerApi(dataForm);
|
||||
}
|
||||
} catch (error) {
|
||||
applyServerErrors(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.visible = false;
|
||||
await handleQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскладывает отказ сервера по полям формы.
|
||||
*
|
||||
* Если причина не относится ни к одному полю — это отказ уровня операции
|
||||
* (например, имя уже занято другим пиром при переименовании), и он
|
||||
* показывается тостом. Диалог при этом остаётся открытым: закрывать форму,
|
||||
* потерявшую введённое, из-за исправимой ошибки нельзя.
|
||||
*/
|
||||
function applyServerErrors(error: unknown) {
|
||||
if (!isApiError(error)) {
|
||||
// Транспортный отказ уже показан общим перехватчиком.
|
||||
return;
|
||||
}
|
||||
|
||||
const byField = fieldErrorMap(error);
|
||||
Object.assign(serverErrors, byField);
|
||||
|
||||
if (Object.keys(byField).length === 0) {
|
||||
ElMessage.error(describeApiError(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Единственная точка входа для действий меню строки.
|
||||
*
|
||||
* Раньше каждый пункт нёс собственный `@click`. Разница не только
|
||||
* стилистическая: el-dropdown объявляет `command` штатным способом описывать
|
||||
* меню действий, и при нём невозможно добавить пункт, забыв его подключить.
|
||||
*/
|
||||
function runCommand(command: PeerCommand, row: PeerVo) {
|
||||
switch (command) {
|
||||
case "qr":
|
||||
return showQr(row);
|
||||
case "edit":
|
||||
return handleUpdate(row);
|
||||
case "toggle-disabled":
|
||||
return handleToggleDisabled(row);
|
||||
case "reset-traffic":
|
||||
return handleResetTraffic(row);
|
||||
case "kick":
|
||||
return handleKick(row);
|
||||
case "release-kick":
|
||||
return handleReleaseKick(row);
|
||||
case "delete":
|
||||
return handleDelete(row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждение, отмена которого является ОТВЕТОМ, а не ошибкой.
|
||||
*
|
||||
* ElMessageBox отклоняет промис при нажатии «Отмена». Прежний код писал
|
||||
* `await ElMessageBox.confirm(...)` без разбора отказа, поэтому каждая отмена
|
||||
* оставляла в консоли необработанное отклонение промиса — в четырёх местах
|
||||
* страницы сразу.
|
||||
*/
|
||||
async function confirmAction(
|
||||
message: string,
|
||||
title = t("common.warning"),
|
||||
options: Record<string, unknown> = { type: "warning" }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отказ операции над пиром, показанный оператору один раз.
|
||||
*
|
||||
* Отдельно разбирается ЧАСТИЧНЫЙ результат отзыва доступа: запрет новых
|
||||
* подключений в базу записан, а активную сессию завершить не удалось. Это не
|
||||
* отказ операции, и показывать его как ошибку означало бы подтолкнуть
|
||||
* оператора к неверному выводу «не сработало, состояние прежнее». Список при
|
||||
* этом обновляется: строка обязана показать уже применённое состояние.
|
||||
*/
|
||||
function reportPeerActionError(error: unknown): void {
|
||||
if (!isApiError(error)) {
|
||||
// Транспортный отказ уже показан общим перехватчиком.
|
||||
return;
|
||||
}
|
||||
if (error.hasCode(ERR_CODE.peerDisconnectFailed)) {
|
||||
ElMessage.warning(describeApiError(error));
|
||||
return;
|
||||
}
|
||||
ElMessage.error(describeApiError(error));
|
||||
}
|
||||
|
||||
async function handleDelete(row: PeerVo) {
|
||||
await ElMessageBox.confirm(
|
||||
t("common.deleteConfirm", { username: row.name }),
|
||||
t("common.warning"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
await deletePeerApi({ id: row.id });
|
||||
if (
|
||||
!(await confirmAction(t("common.deleteConfirm", { username: row.name })))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deletePeerApi({ id: row.id });
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (error) {
|
||||
reportPeerActionError(error);
|
||||
}
|
||||
await handleQuery();
|
||||
}
|
||||
|
||||
async function handleResetTraffic(row: PeerVo) {
|
||||
await ElMessageBox.confirm(
|
||||
t("common.resetTrafficConfirm"),
|
||||
t("common.warning"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
await resetPeerTrafficApi({ id: row.id });
|
||||
if (!(await confirmAction(t("common.resetTrafficConfirm")))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resetPeerTrafficApi({ id: row.id });
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (error) {
|
||||
reportPeerActionError(error);
|
||||
}
|
||||
await handleQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Быстрое включение/отключение пира.
|
||||
*
|
||||
* Отключение — это ОБЕ половины официального контракта Hysteria: запрет новых
|
||||
* подключений в базе и разрыв уже установленной сессии через Traffic Stats
|
||||
* `/kick`. Обе выполняет сервер; панели остаётся сообщить о результате, в том
|
||||
* числе о частичном, и показать применённое состояние.
|
||||
*
|
||||
* Подтверждение спрашивается только на отключении: включение доступа обратимо
|
||||
* одним движением, отключение обрывает работающее соединение.
|
||||
*/
|
||||
async function handleToggleDisabled(row: PeerVo) {
|
||||
const disable = row.disabled !== 1;
|
||||
|
||||
if (
|
||||
disable &&
|
||||
!(await confirmAction(t("peer.disableConfirm", { name: row.name })))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePeerApi({ id: row.id, disabled: disable ? 1 : 0 });
|
||||
ElMessage.success(
|
||||
disable ? t("peer.disableSuccess") : t("peer.enableSuccess")
|
||||
);
|
||||
} catch (error) {
|
||||
reportPeerActionError(error);
|
||||
}
|
||||
// Список обновляется в любом случае: при частичном результате состояние в
|
||||
// базе уже изменилось, и строка обязана это показать.
|
||||
await handleQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Временная блокировка.
|
||||
*
|
||||
* Срок называется оператору явно. Раньше значение `Date.now() + час` было
|
||||
* зашито в обработчик, нигде не показывалось и не подтверждалось: пункт меню
|
||||
* «Отключить» молча блокировал пира на час, а сколько именно продлится
|
||||
* блокировка, не сообщалось ни до, ни после.
|
||||
*
|
||||
* Механизм независим от «Отключить пир»: у временной блокировки свой срок,
|
||||
* который истекает сам, и снимается она отдельным пунктом.
|
||||
*/
|
||||
async function handleKick(row: PeerVo) {
|
||||
await kickPeerApi(row.id, { bannedUntil: Date.now() + 60 * 60 * 1000 });
|
||||
const bannedUntil = Date.now() + KICK_DURATION_MS;
|
||||
|
||||
if (
|
||||
!(await confirmAction(
|
||||
t("peer.kickConfirm", {
|
||||
name: row.name,
|
||||
until: timestampToDateTime(bannedUntil),
|
||||
})
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await kickPeerApi(row.id, { bannedUntil });
|
||||
ElMessage.success(t("peer.kickSuccess"));
|
||||
} catch (error) {
|
||||
reportPeerActionError(error);
|
||||
}
|
||||
await handleQuery();
|
||||
}
|
||||
|
||||
async function handleReleaseKick(row: PeerVo) {
|
||||
await releaseKickPeerApi({ id: row.id });
|
||||
try {
|
||||
await releaseKickPeerApi({ id: row.id });
|
||||
ElMessage.success(t("peer.releaseSuccess"));
|
||||
} catch (error) {
|
||||
reportPeerActionError(error);
|
||||
}
|
||||
await handleQuery();
|
||||
}
|
||||
|
||||
async function copyUri(row: PeerVo) {
|
||||
// Копирование без подтверждения выглядит как ничего не сделавшая кнопка:
|
||||
// буфер обмена оператору не виден.
|
||||
const { data } = await getPeerClientConfigApi(row.id);
|
||||
copy(data.url);
|
||||
ElMessage.success(t("common.copySuccess"));
|
||||
}
|
||||
|
||||
async function showQr(row: PeerVo) {
|
||||
@@ -576,13 +977,30 @@ async function showQr(row: PeerVo) {
|
||||
qrDialog.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт выгрузки пиров.
|
||||
*
|
||||
* Исход разбирается тем же обработчиком, что и действия строки: импорт умеет
|
||||
* завершиться ЧАСТИЧНО — партия применена целиком, но завершить старые сессии
|
||||
* обновлённых пиров не удалось.
|
||||
*
|
||||
* Раньше здесь не было ни try, ни catch: отказ уходил необработанным
|
||||
* отклонением промиса, а `handleQuery()` до выполнения не доходил — список
|
||||
* оставался с прежними данными, хотя база уже изменилась. Убирается файл из
|
||||
* очереди и обновляется список ПРИ ЛЮБОМ исходе по той же причине.
|
||||
*/
|
||||
async function handleImport(params: UploadRequestOptions) {
|
||||
if (importFileList.value.length <= 0) {
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", params.file);
|
||||
await importPeerApi(formData);
|
||||
try {
|
||||
await importPeerApi(formData);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (error) {
|
||||
reportPeerActionError(error);
|
||||
}
|
||||
importFileList.value = [];
|
||||
await handleQuery();
|
||||
}
|
||||
@@ -602,20 +1020,12 @@ function beforeImport(file: UploadRawFile) {
|
||||
async function downloadExport(includeSecrets: boolean) {
|
||||
try {
|
||||
const response = await exportPeerApi(includeSecrets);
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
a.href = url;
|
||||
const dis = response.headers["content-disposition"] || "";
|
||||
a.download = dis.split("attachment; filename=")[1] || "peers-export.json";
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
saveResponseAsFile(response, "peers-export.json");
|
||||
ElMessage.success(t("common.downloadSuccess"));
|
||||
} catch {
|
||||
ElMessage.error(t("common.invalid"));
|
||||
// Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
|
||||
// общий разбор конверта: у Blob нет полей code и errors.
|
||||
ElMessage.error(t("common.systemError"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +1038,7 @@ async function handleExport() {
|
||||
// Подтверждение здесь обязательно — это единственное место в панели, где
|
||||
// оператор осознанно выгружает наружу работающие учётные данные доступа.
|
||||
async function handleExportBackup() {
|
||||
await ElMessageBox.confirm(
|
||||
const confirmed = await confirmAction(
|
||||
t("peer.exportBackupConfirm"),
|
||||
t("common.securityRisk"),
|
||||
{
|
||||
@@ -638,6 +1048,9 @@ async function handleExportBackup() {
|
||||
confirmButtonClass: "el-button--danger",
|
||||
}
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await downloadExport(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,17 @@ go 1.25.0
|
||||
//
|
||||
// Директива `go` выше — это языковой baseline модуля, и она НЕ выбирает
|
||||
// компилятор: с ней одной локальный `go build` на 1.25 проходил успешно, хотя
|
||||
// релизный бинарь собирается на 1.26.7 и наследует её stdlib. То есть
|
||||
// релизный бинарь собирается версией из versions.env и наследует её stdlib.
|
||||
// Номер здесь не повторяется намеренно — он живёт строкой ниже и в
|
||||
// GO_VERSION, а третья копия в прозе устаревала бы на каждом patch-релизе. То
|
||||
// есть
|
||||
// разработчик и сборка проверяли разный код, а расхождение не было видно ни в
|
||||
// одном из выводов.
|
||||
//
|
||||
// Значение обязано совпадать с GO_VERSION из versions.env; это проверяет
|
||||
// verify_go_toolchain_contract, а `tools/dev/doctor` показывает то же
|
||||
// расхождение локально, до сборки.
|
||||
toolchain go1.26.7
|
||||
toolchain go1.26.8
|
||||
|
||||
require (
|
||||
github.com/didip/tollbooth v4.0.2+incompatible
|
||||
|
||||
@@ -12,18 +12,18 @@ func AdminHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claimsRaw, ok := c.Get("adminClaims")
|
||||
if !ok {
|
||||
vo.Fail(constant.UnauthorizedError, c)
|
||||
vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
claims, castOK := claimsRaw.(bo.AccountBo)
|
||||
if !castOK {
|
||||
vo.Fail(constant.IllegalTokenError, c)
|
||||
vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !util.ArrContain(claims.Roles, "admin") {
|
||||
vo.Fail(constant.ForbiddenError, c)
|
||||
vo.FailForbidden(constant.ForbiddenError, c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Отказ аутентификации несёт КОД состояния сессии.
|
||||
//
|
||||
// Раньше все ветки здесь звали vo.Fail с человеческой строкой, а код ответа
|
||||
// выводился в vo сравнением этой строки с тремя известными литералами. Под
|
||||
// условия подходил только `unauthorized`; `token expired` и `authentication
|
||||
// failed` уезжали к панели как обычная системная ошибка с кодом 50000.
|
||||
//
|
||||
// Следствие было видимым для оператора: истёкшая сессия на открытой странице
|
||||
// давала голый тост «token expired», ветка «войдите заново» не срабатывала
|
||||
// никогда, а перебросить на форму входа мог только переход по маршруту,
|
||||
// которому потребовался бы getAdminInfo. Ключ локализации `common.sessionExpired`
|
||||
// при этом существовал и был мёртвым.
|
||||
func JWTHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.Request.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
vo.Fail(constant.UnauthorizedError, c)
|
||||
vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
vo.Fail(constant.IllegalTokenError, c)
|
||||
vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
myClaims, err := service.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
vo.Fail(err.Error(), c)
|
||||
vo.FailUnauthorized(tokenErrorCode(err), err.Error(), c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
admin, err := service.GetAdminForTokenValidation(myClaims.Admin.Id)
|
||||
if err != nil {
|
||||
// Это уже не состояние сессии, а отказ чтения учётной записи:
|
||||
// сворачивать его в «войдите заново» значило бы отправлять
|
||||
// оператора на форму входа при недоступной базе.
|
||||
vo.Fail(err.Error(), c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if admin.Status != nil && *admin.Status != 1 {
|
||||
vo.Fail("this account has been disabled", c)
|
||||
vo.FailUnauthorized(
|
||||
constant.ErrCodeAccountDisabled,
|
||||
"this account has been disabled",
|
||||
c,
|
||||
)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -44,7 +65,9 @@ func JWTHandler() gin.HandlerFunc {
|
||||
tokenVersion = *admin.TokenVersion
|
||||
}
|
||||
if myClaims.Admin.TokenVersion != tokenVersion {
|
||||
vo.Fail(constant.IllegalTokenError, c)
|
||||
// Версия токена сменилась: пароль изменён или доступ отозван.
|
||||
// Для оператора это неотличимо от истёкшей сессии — вход заново.
|
||||
vo.FailUnauthorized(constant.ErrCodeSessionExpired, constant.IllegalTokenError, c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -52,3 +75,21 @@ func JWTHandler() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// tokenErrorCode различает истёкший токен и недействительный.
|
||||
//
|
||||
// Вопрос задаётся ЗНАЧЕНИЮ ошибки, а не её тексту: service.ParseToken
|
||||
// возвращает объявленные значения, поэтому правка формулировки сообщения не
|
||||
// может молча превратить истёкшую сессию в неизвестную ошибку.
|
||||
//
|
||||
// Отказ прочитать ключ подписи (недоступная база) сюда тоже приходит, и это
|
||||
// НЕ состояние сессии. Отдельного кода он не получает намеренно: снаружи
|
||||
// панели такой отказ неотличим от недействительного токена, и предлагать
|
||||
// оператору войти заново — единственное осмысленное действие, которое ему
|
||||
// доступно.
|
||||
func tokenErrorCode(err error) string {
|
||||
if errors.Is(err, service.ErrTokenExpired) {
|
||||
return constant.ErrCodeSessionExpired
|
||||
}
|
||||
return constant.ErrCodeTokenInvalid
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -79,13 +80,37 @@ func LogHandler() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
entry := logrus.WithFields(fields)
|
||||
message := RequestLogMessage(c.Request.Method, c.Request.URL.Path, statusCode, latencyTime)
|
||||
|
||||
if statusCode >= 500 {
|
||||
entry.Error()
|
||||
entry.Error(message)
|
||||
} else if statusCode >= 400 {
|
||||
entry.Warn()
|
||||
entry.Warn(message)
|
||||
} else {
|
||||
entry.Info()
|
||||
entry.Info(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequestLogMessage собирает человекочитаемое `msg` записи журнала.
|
||||
//
|
||||
// Раньше здесь стояли `entry.Error()`, `entry.Warn()` и `entry.Info()` — без
|
||||
// аргумента. Поля собирались правильно, но logrus честно записывал `"msg":""`
|
||||
// для КАЖДОГО HTTP-запроса, и страница системных логов показывала оператору
|
||||
// пустой столбец сообщений. Дефект выглядел как рассогласование модели
|
||||
// отображения с форматом файла, а был ровно тем, что backend записал.
|
||||
//
|
||||
// Строка собирается ИЗ ТЕХ ЖЕ величин, что уже лежат в структурных полях, и
|
||||
// не добавляет к ним ничего нового. Это сознательно: запись остаётся
|
||||
// машиночитаемой, а `msg` существует, чтобы человек мог прочитать её глазами,
|
||||
// не собирая строку из шести колонок.
|
||||
//
|
||||
// Query-строка сюда не попадает, и это не небрежность, а действующий контракт
|
||||
// безопасности. Hysteria обращается к машинному endpoint'у как
|
||||
// /internal/hysteria/auth?access_token=<machine token> при каждом подключении
|
||||
// пира; журнал отдаётся оператору через ExportLog и уезжает в
|
||||
// diagnostics-бандл. Логируется путь, а из query — только имена параметров,
|
||||
// отдельным полем (см. requestQueryKeys).
|
||||
func RequestLogMessage(method string, path string, statusCode int, latency time.Duration) string {
|
||||
return fmt.Sprintf("%s %s → %d (%d ms)", method, path, statusCode, latency.Milliseconds())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -89,6 +90,71 @@ func TestLogHandlerOmitsQueryKeysWhenThereAreNone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия LOG-01: `entry.Info()` вызывался БЕЗ аргумента, и logrus честно
|
||||
// записывал `"msg":""` для каждого HTTP-запроса. Страница системных логов
|
||||
// показывала оператору пустой столбец сообщений — она отражала ровно то, что
|
||||
// backend записал.
|
||||
func TestLogHandlerWritesNonEmptyMessage(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/peers", nil)
|
||||
|
||||
logged := captureLog(t, request)
|
||||
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(logged)), &record); err != nil {
|
||||
t.Fatalf("запись журнала не разбирается как JSON: %s", logged)
|
||||
}
|
||||
|
||||
message, _ := record["msg"].(string)
|
||||
if strings.TrimSpace(message) == "" {
|
||||
t.Fatalf("запись журнала пришла с пустым msg: %s", logged)
|
||||
}
|
||||
|
||||
// `msg` обязан быть читаемым сам по себе: ради этого он и существует.
|
||||
for _, fragment := range []string{"GET", "/api/peers", "200"} {
|
||||
if !strings.Contains(message, fragment) {
|
||||
t.Errorf("в msg нет %q: %q", fragment, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Структурные поля при этом остаются на месте — `msg` их не заменяет.
|
||||
for _, field := range []string{"reqMethod", "reqPath", "statusCode", "latencyTime", "clientIP"} {
|
||||
if _, ok := record[field]; !ok {
|
||||
t.Errorf("структурное поле %q исчезло из записи: %s", field, logged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Человекочитаемое сообщение НЕ является лазейкой для query-строки: контракт
|
||||
// безопасности сильнее удобства чтения.
|
||||
func TestLogHandlerMessageNeverCarriesQueryValues(t *testing.T) {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/internal/hysteria/auth?access_token="+sentinel,
|
||||
strings.NewReader("{}"),
|
||||
)
|
||||
|
||||
logged := captureLog(t, request)
|
||||
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(logged)), &record); err != nil {
|
||||
t.Fatalf("запись журнала не разбирается как JSON: %s", logged)
|
||||
}
|
||||
|
||||
message, _ := record["msg"].(string)
|
||||
if strings.TrimSpace(message) == "" {
|
||||
t.Fatalf("запись журнала пришла с пустым msg: %s", logged)
|
||||
}
|
||||
if strings.Contains(message, sentinel) {
|
||||
t.Fatalf("machine token попал в msg: %q", message)
|
||||
}
|
||||
if strings.Contains(message, "?") {
|
||||
t.Fatalf("query-строка попала в msg: %q", message)
|
||||
}
|
||||
if !strings.Contains(message, "/internal/hysteria/auth") {
|
||||
t.Fatalf("в msg нет пути запроса: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Значение секретоподобного параметра не должно попасть в журнал ни на каком
|
||||
// маршруте, а не только на машинном.
|
||||
func TestLogHandlerRedactsQueryValuesOnOperatorRoutes(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Состояние сессии сообщается КОДОМ, а не текстом.
|
||||
//
|
||||
// Регрессия. Все отказы аутентификации звали vo.Fail с человеческой строкой, а
|
||||
// код ответа выводился сравнением этой строки с тремя известными литералами.
|
||||
// Под условия подходил только `unauthorized`; истёкший токен уезжал с кодом
|
||||
// системной ошибки 50000, панель показывала оператору голый тост
|
||||
// «token expired» и не понимала, что сессия кончилась. Ключ локализации
|
||||
// common.sessionExpired существовал и был мёртвым, а вернуть оператора на
|
||||
// форму входа мог только переход по маршруту, которому потребовался бы
|
||||
// getAdminInfo.
|
||||
|
||||
type authResponse struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Errors []vo.FieldError `json:"errors"`
|
||||
}
|
||||
|
||||
func callJWTHandler(t *testing.T, header string) authResponse {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/guarded", JWTHandler(), func(c *gin.Context) {
|
||||
vo.Success(nil, c)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/guarded", nil)
|
||||
if header != "" {
|
||||
request.Header.Set("Authorization", header)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var parsed authResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestJWTHandlerReportsMissingCredentials(t *testing.T) {
|
||||
response := callJWTHandler(t, "")
|
||||
|
||||
if response.Code != constant.CodeUnauthorizedError {
|
||||
t.Fatalf("код ответа %d, ожидался %d", response.Code, constant.CodeUnauthorizedError)
|
||||
}
|
||||
if len(response.Errors) != 1 || response.Errors[0].Code != constant.ErrCodeUnauthorized {
|
||||
t.Fatalf("неожиданное описание отказа: %+v", response.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTHandlerReportsMalformedAuthorizationHeader(t *testing.T) {
|
||||
for _, header := range []string{"token-without-scheme", "Basic dXNlcjpwYXNz"} {
|
||||
response := callJWTHandler(t, header)
|
||||
|
||||
if response.Code != constant.CodeUnauthorizedError {
|
||||
t.Errorf("заголовок %q: код ответа %d, ожидался %d",
|
||||
header, response.Code, constant.CodeUnauthorizedError)
|
||||
continue
|
||||
}
|
||||
if len(response.Errors) != 1 || response.Errors[0].Code != constant.ErrCodeTokenInvalid {
|
||||
t.Errorf("заголовок %q: неожиданное описание отказа: %+v", header, response.Errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Истёкшая сессия обязана быть отличима от недействительного токена: панель
|
||||
// показывает оператору разные вещи и по-разному его возвращает на вход.
|
||||
func TestTokenErrorCodeSeparatesExpiryFromInvalidity(t *testing.T) {
|
||||
if code := tokenErrorCode(service.ErrTokenExpired); code != constant.ErrCodeSessionExpired {
|
||||
t.Errorf("истёкший токен получил код %q, ожидался %q", code, constant.ErrCodeSessionExpired)
|
||||
}
|
||||
if code := tokenErrorCode(service.ErrTokenInvalid); code != constant.ErrCodeTokenInvalid {
|
||||
t.Errorf("недействительный токен получил код %q, ожидался %q", code, constant.ErrCodeTokenInvalid)
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,82 @@ const (
|
||||
WrongPassword string = "wrong password"
|
||||
ConfigNotExist string = "config not exist"
|
||||
)
|
||||
|
||||
// Коды структурированных ошибок.
|
||||
//
|
||||
// Зачем они есть. Раньше единственным машиночитаемым признаком ошибки был
|
||||
// числовой `code` ответа, а всё остальное жило в человеческом тексте: слой vo
|
||||
// выбирал HTTP-семантику СРАВНЕНИЕМ строки сообщения, а панель показывала
|
||||
// оператору голое «invalid» на любую ошибку любого поля формы. Оба места
|
||||
// разбирали прозу — то есть договор между сервером и панелью держался на
|
||||
// совпадении литералов, которое ничто не проверяло.
|
||||
//
|
||||
// Теперь у ошибки есть код и — там, где ошибка относится к полю, — имя поля.
|
||||
// Панель выбирает по коду свою локализованную строку и не разбирает текст;
|
||||
// `message` остаётся человекочитаемым ответом для клиента без UI и запасным
|
||||
// вариантом для кода, которого панель ещё не знает.
|
||||
//
|
||||
// Коды — часть публичного контракта API: их значения не меняются вместе с
|
||||
// формулировками сообщений.
|
||||
const (
|
||||
// ErrCodeBodyInvalid — тело запроса не разобралось: не JSON, не тот тип
|
||||
// поля, сломанная query-строка. Это отказ ДО проверки правил.
|
||||
ErrCodeBodyInvalid string = "body_invalid"
|
||||
// ErrCodeValidationFailed — общий код ответа, у которого есть errors[].
|
||||
ErrCodeValidationFailed string = "validation_failed"
|
||||
|
||||
// Коды правил. Совпадают с именами тегов валидатора: одно правило — один
|
||||
// код, и никакого второго словаря соответствий.
|
||||
ErrCodeRequired string = "required"
|
||||
// Границы числа и границы длины строки различаются кодом, хотя тег
|
||||
// валидатора у них один. Оператору это разные фразы: «не меньше 1
|
||||
// устройства» и «не короче 6 символов», — и панель обязана уметь их
|
||||
// различить, не заводя у себя таблицу «какое поле какого рода».
|
||||
ErrCodeMin string = "min"
|
||||
ErrCodeMax string = "max"
|
||||
ErrCodeMinLength string = "min_length"
|
||||
ErrCodeMaxLength string = "max_length"
|
||||
ErrCodeLen string = "len"
|
||||
ErrCodeOneOf string = "oneof"
|
||||
ErrCodeGreaterThan string = "gt"
|
||||
ErrCodePeerName string = "peer_name"
|
||||
ErrCodeCredentialStr string = "credential_format"
|
||||
// ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту.
|
||||
//
|
||||
// Отдельный код, а не пара min_length/max_length, потому что правило одно и
|
||||
// проверяет три вещи сразу: длину в символах, длину в байтах (предел
|
||||
// bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя
|
||||
// тегами, отказ по байтам было нечем назвать — его просто не существовало,
|
||||
// и пароль из 64 кириллических букв доходил до хеширования, где становился
|
||||
// системной ошибкой.
|
||||
ErrCodeAdminPassword string = "admin_password_format"
|
||||
ErrCodeRuleUnknown string = "rule_violated"
|
||||
|
||||
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
|
||||
ErrCodePeerNameTaken string = "peer_name_taken"
|
||||
ErrCodePeerNameReserved string = "peer_name_reserved"
|
||||
ErrCodePeerBootstrapLocked string = "peer_bootstrap_identity_locked"
|
||||
ErrCodeInvalidCredentials string = "invalid_credentials"
|
||||
ErrCodeImportFileExtension string = "import_file_extension"
|
||||
|
||||
// ErrCodePeerDisconnectFailed — ЧАСТИЧНЫЙ результат, а не отказ операции.
|
||||
//
|
||||
// Отзыв доступа состоит из двух половин: долговременной записи в базе,
|
||||
// которая закрывает новые подключения, и обращения к Traffic Stats `/kick`,
|
||||
// которое рвёт уже установленную сессию. Первая половина к моменту этого
|
||||
// кода уже применена и НЕ откатывается: достигнутое безопасное состояние
|
||||
// нельзя отменять из-за неудачи второго шага — откат вернул бы пиру полный
|
||||
// доступ.
|
||||
//
|
||||
// Поэтому код нужен отдельный. Без него оператор видел бы обычную ошибку и
|
||||
// сделал бы ровно неверный вывод: «не сработало, состояние прежнее».
|
||||
ErrCodePeerDisconnectFailed string = "peer_disconnect_failed"
|
||||
|
||||
// Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»:
|
||||
// во втором случае оператор находится на рабочей странице, и молча
|
||||
// выбрасывать его на форму входа без объяснения нельзя.
|
||||
ErrCodeUnauthorized string = "unauthorized"
|
||||
ErrCodeSessionExpired string = "session_expired"
|
||||
ErrCodeTokenInvalid string = "token_invalid"
|
||||
ErrCodeAccountDisabled string = "account_disabled"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
package dto
|
||||
|
||||
// Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ.
|
||||
//
|
||||
// Пока границы расходились, панель запирала оператора снаружи после операции,
|
||||
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
|
||||
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
|
||||
// невозможно войти.
|
||||
//
|
||||
// Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом
|
||||
// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает
|
||||
// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических
|
||||
// букв проходил проверку формы, доходил до util.HashPassword и возвращался
|
||||
// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить
|
||||
// нечем — правила длины в байтах у него нет.
|
||||
//
|
||||
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
|
||||
|
||||
type AdminChangePasswordDto struct {
|
||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
|
||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
|
||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"`
|
||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,adminPassword"`
|
||||
}
|
||||
|
||||
// Normalize: пароль НЕ триммится ни в одном из полей.
|
||||
//
|
||||
// Пустое поле обязано выглядеть незаполненным и получить отказ `required`: у
|
||||
// поля-указателя `required` считает значение заданным по самому факту
|
||||
// ненулевого указателя, поэтому `{"oldPassword":""}` иначе получило бы отказ по
|
||||
// длине вместо «поле обязательно».
|
||||
//
|
||||
// Пробел по краям при этом остаётся частью значения. Форма входа ведёт себя так
|
||||
// же (LoginDto.Normalize), и иначе быть не может: пароль, назначенный здесь,
|
||||
// вводится там.
|
||||
func (d *AdminChangePasswordDto) Normalize() {
|
||||
emptyToNil(&d.OldPassword)
|
||||
emptyToNil(&d.NewPassword)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
package dto
|
||||
|
||||
// Контракт формы входа.
|
||||
//
|
||||
// Раньше на Username стояло `required,min=6,max=32,validateStr`, и правила
|
||||
// `validateStr` не существовало: оно пережило переименование в `credentialStr`
|
||||
// и осталось ссылкой на незарегистрированную функцию. go-playground/validator в
|
||||
// таком случае ПАНИКУЕТ, поэтому каждый POST /api/auth/login заканчивался
|
||||
// паникой, которую gin.Recovery превращал в HTTP 500 — панель после чистой
|
||||
// установки была недоступна целиком. Ни один тест этого не видел: проверялся
|
||||
// сам регексп, а не LoginDto через production-валидатор, и цепочка правил на
|
||||
// нулевом DTO обрывается на `required` раньше, чем доходит до испорченного
|
||||
// тега.
|
||||
//
|
||||
// Границы длины логина живут ВНУТРИ правила `credentialStr`, а не рядом с ним.
|
||||
// Два правила на одном поле уже приводили к отказу, который невозможно
|
||||
// объяснить: имя пира с `min=1,max=32,validateStr` принималось одним правилом
|
||||
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
|
||||
// была бы той же ошибкой.
|
||||
//
|
||||
// У пароля границы длины живут внутри правила `adminPassword` по той же
|
||||
// причине. Здесь стояло `min=6,max=64`, и эта запись была не только вторым
|
||||
// правилом на одном поле, но и НЕПОЛНОЙ: `max` считает символы Unicode, а
|
||||
// bcrypt — байты, поэтому 64 кириллические буквы (128 байт) проходили проверку
|
||||
// и падали в хешировании. Границу в байтах тегом валидатора не выразить.
|
||||
//
|
||||
// Набора символов у пароля НЕТ, и это осознанно: сервер нигде его не
|
||||
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
|
||||
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
|
||||
// только запирать оператора с паролем, который сервер принял бы. Единственное
|
||||
// исключение — управляющие символы, и оно принадлежит контракту, а не форме:
|
||||
// см. credential.IsValidAdminPassword.
|
||||
type LoginDto struct {
|
||||
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"`
|
||||
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
|
||||
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
|
||||
Pass *string `json:"pass" form:"pass" validate:"required,adminPassword"`
|
||||
}
|
||||
|
||||
// Normalize: незаполненное поле обязано выглядеть незаполненным.
|
||||
//
|
||||
// У поля-указателя `required` считает значение заданным по ненулевому
|
||||
// указателю, поэтому `{"username":"","pass":""}` проходило `required` целиком и
|
||||
// получало отказ по набору символов и по длине — то есть оператор, отправивший
|
||||
// пустую форму, читал про недопустимые символы вместо «поле обязательно».
|
||||
//
|
||||
// Логин при этом ещё и триммится: скопированное из bootstrap-admin.secret
|
||||
// значение регулярно приезжает с пробелом или переводом строки на конце, и
|
||||
// раньше это давало «неверный логин или пароль» — отказ, который невозможно
|
||||
// связать с причиной.
|
||||
//
|
||||
// Пароль НЕ триммится. Набор его символов не ограничен, пробел по краям —
|
||||
// законная часть значения, и молчаливое его удаление означало бы, что панель
|
||||
// отправляет не то, что ввёл оператор.
|
||||
func (d *LoginDto) Normalize() {
|
||||
blankToNil(&d.Username)
|
||||
emptyToNil(&d.Pass)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,16 @@ type BaseDto struct {
|
||||
EndTime *int64 `json:"endTime" form:"endTime" validate:"omitempty,gt=0"` // Время окончания
|
||||
}
|
||||
|
||||
// Normalize: нулевая отметка времени — это отсутствие фильтра.
|
||||
//
|
||||
// Правило `omitempty,gt=0` на указателе не пропускается (см. normalize.go),
|
||||
// поэтому пришедший `startTime=0` отказывал бы вместо того, чтобы означать
|
||||
// «без ограничения снизу».
|
||||
func (d *BaseDto) Normalize() {
|
||||
zeroToNil(&d.StartTime)
|
||||
zeroToNil(&d.EndTime)
|
||||
}
|
||||
|
||||
type IdDto struct {
|
||||
Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ type LogDto struct {
|
||||
NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"`
|
||||
}
|
||||
|
||||
// Normalize: «показать 0 строк» — это не запрос, а пропущенный параметр.
|
||||
func (d *LogDto) Normalize() {
|
||||
zeroToNil(&d.NumLine)
|
||||
}
|
||||
|
||||
type LogExportDto struct {
|
||||
Option *int `json:"option" form:"option" validate:"required,oneof=0 1"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package dto
|
||||
|
||||
import "strings"
|
||||
|
||||
// Приведение входа к каноничному виду ДО проверки правил.
|
||||
//
|
||||
// Зачем это нужно. В go-playground/validator тег `omitempty` НЕ пропускает
|
||||
// правило, если поле объявлено указателем, а указатель не nil. Помощник
|
||||
// `hasValue` (baked_in.go) устроен так:
|
||||
//
|
||||
// if fl.(*validate).fldIsPointer && getValue(field) != nil {
|
||||
// return true
|
||||
// }
|
||||
//
|
||||
// Для `*string`, указывающего на пустую строку, это возвращает true, то есть
|
||||
// «значение есть». В результате `omitempty,min=6` на поле `Secret` срабатывало
|
||||
// именно тогда, когда оператор НИЧЕГО не ввёл: панель отправляла `secret: ""`,
|
||||
// правило `min=6` применялось к пустой строке и отказывало. Панель при этом
|
||||
// писала под полем «оставьте пустым — сгенерируем автоматически», а сервер
|
||||
// умел это сделать: генерация в CreatePeer существовала и была недостижима.
|
||||
//
|
||||
// Чинить это тегом на одном поле бессмысленно: ловушка одинаково стоит на
|
||||
// фильтре списка пиров (очищенный `el-input` шлёт `?name=`, правило `min=1`
|
||||
// отказывает поиску), на необязательных отметках времени и на всяком будущем
|
||||
// необязательном поле-указателе. Поэтому нормализация — общий шаг конвейера, а
|
||||
// не особый случай «если пусто, подставь строку».
|
||||
//
|
||||
// Правило формулируется ПОФАКТИЧЕСКИ, для каждого поля отдельно, и это
|
||||
// сознательно. Пустая строка не везде означает «не задано»: у `remark` она
|
||||
// означает «очистить пометку», и общее «пусто → nil» молча лишило бы оператора
|
||||
// возможности её убрать. Ноль у `disabled` и `quotaBytes` — законное значение,
|
||||
// а не пропуск.
|
||||
|
||||
// Normalizable — DTO, приводящее свой вход к каноничному виду.
|
||||
//
|
||||
// Вызывается слоем контроллеров между разбором тела и проверкой правил, то
|
||||
// есть ровно один раз и для всех дверей одинаково.
|
||||
type Normalizable interface {
|
||||
Normalize()
|
||||
}
|
||||
|
||||
// blankToNil: «пусто или одни пробелы» становится «не задано».
|
||||
//
|
||||
// Применяется к полям, у которых отсутствие значения — законный вход.
|
||||
func blankToNil(field **string) {
|
||||
if *field == nil {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(**field)
|
||||
if trimmed == "" {
|
||||
*field = nil
|
||||
return
|
||||
}
|
||||
*field = &trimmed
|
||||
}
|
||||
|
||||
// trimValue убирает окружающие пробелы, сохраняя само поле заданным.
|
||||
//
|
||||
// Применяется к обязательным полям и к тем, у которых пустая строка — это
|
||||
// значение, а не пропуск. Пустой ввод после тримминга остаётся пустым и
|
||||
// получит внятный отказ от `required`, а не молча превратится в «не задано».
|
||||
func trimValue(field *string) {
|
||||
if field == nil {
|
||||
return
|
||||
}
|
||||
*field = strings.TrimSpace(*field)
|
||||
}
|
||||
|
||||
// emptyToNil: пустая строка означает «не задано», и ничего больше.
|
||||
//
|
||||
// Отличается от blankToNil тем, что НЕ трогает пробелы. Применяется к полям,
|
||||
// содержимое которых принадлежит оператору целиком, — к паролю: набор его
|
||||
// символов не ограничен, пробел по краям является частью значения, и
|
||||
// молчаливое его удаление означало бы, что на сервер уезжает не то, что было
|
||||
// введено. Пустое поле при этом обязано получить отказ `required`, а не отказ
|
||||
// по длине: у поля-указателя `required` считает значение заданным по самому
|
||||
// факту ненулевого указателя.
|
||||
func emptyToNil(field **string) {
|
||||
if *field == nil {
|
||||
return
|
||||
}
|
||||
if **field == "" {
|
||||
*field = nil
|
||||
}
|
||||
}
|
||||
|
||||
// zeroToNil: ноль у необязательного числового поля означает «не задано».
|
||||
//
|
||||
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
|
||||
// «показать 0 строк журнала» и «время начала — 1 января 1970 года» — это
|
||||
// пропуск фильтра, а не запрос.
|
||||
func zeroToNil[T int | int64](field **T) {
|
||||
if *field == nil {
|
||||
return
|
||||
}
|
||||
if **field == 0 {
|
||||
*field = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package dto
|
||||
|
||||
import "testing"
|
||||
|
||||
func strPtr(v string) *string { return &v }
|
||||
func i64Ptr(v int64) *int64 { return &v }
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
// Граница проходит по КАЖДОМУ полю отдельно, и это главное свойство
|
||||
// нормализации.
|
||||
//
|
||||
// Общее правило «пусто → не задано» выглядит соблазнительно и молча ломает
|
||||
// смысл: у комментария пустая строка означает «убрать пометку», у флага
|
||||
// disabled ноль — «включён», у квоты ноль — «нулевая квота». Тест закрепляет,
|
||||
// что эти три случая не попали под общий гребень.
|
||||
|
||||
func TestPeerSaveNormalizeTreatsBlankSecretAsAbsent(t *testing.T) {
|
||||
for _, blank := range []string{"", " ", "\t", "\n", " \t\n "} {
|
||||
d := PeerSaveDto{Name: strPtr("client-01"), Secret: strPtr(blank)}
|
||||
d.Normalize()
|
||||
if d.Secret != nil {
|
||||
t.Errorf("секрет %q не приведён к «не задано»: %q", blank, *d.Secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerSaveNormalizeKeepsManualSecretTrimmed(t *testing.T) {
|
||||
d := PeerSaveDto{Name: strPtr("client-01"), Secret: strPtr(" s3cret-value ")}
|
||||
d.Normalize()
|
||||
|
||||
if d.Secret == nil {
|
||||
t.Fatal("заданный секрет потерян")
|
||||
}
|
||||
if *d.Secret != "s3cret-value" {
|
||||
t.Fatalf("секрет не обрезан по краям: %q", *d.Secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerSaveNormalizeKeepsBlankRemarkAsValue(t *testing.T) {
|
||||
d := PeerSaveDto{Name: strPtr("client-01"), Remark: strPtr(" ")}
|
||||
d.Normalize()
|
||||
|
||||
if d.Remark == nil {
|
||||
t.Fatal("пустая пометка превращена в «не задано»: очистить комментарий станет нечем")
|
||||
}
|
||||
if *d.Remark != "" {
|
||||
t.Fatalf("пометка не обрезана: %q", *d.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerUpdateNormalizeTreatsBlankIdentityFieldsAsAbsent(t *testing.T) {
|
||||
d := PeerUpdateDto{Name: strPtr(" "), Secret: strPtr("")}
|
||||
d.Normalize()
|
||||
|
||||
if d.Name != nil {
|
||||
t.Error("пустое имя при изменении обязано означать «не менять»")
|
||||
}
|
||||
if d.Secret != nil {
|
||||
t.Error("пустой секрет при изменении обязан означать «не менять»")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerUpdateNormalizeKeepsZeroValuedFlags(t *testing.T) {
|
||||
d := PeerUpdateDto{
|
||||
Disabled: i64Ptr(0),
|
||||
QuotaBytes: i64Ptr(0),
|
||||
MaxDevices: i64Ptr(1),
|
||||
}
|
||||
d.Normalize()
|
||||
|
||||
if d.Disabled == nil || *d.Disabled != 0 {
|
||||
t.Error("disabled=0 означает «включён», а не «не задано»")
|
||||
}
|
||||
if d.QuotaBytes == nil || *d.QuotaBytes != 0 {
|
||||
t.Error("quotaBytes=0 означает нулевую квоту, а не «не задано»")
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: очищенный крестиком фильтр отправлялся как `?name=` и отказывал
|
||||
// правилом длины, то есть список пиров ломался в один клик.
|
||||
func TestPeerPageNormalizeDropsClearedFilters(t *testing.T) {
|
||||
d := PeerPageDto{Name: strPtr(""), Remark: strPtr(" ")}
|
||||
d.Normalize()
|
||||
|
||||
if d.Name != nil || d.Remark != nil {
|
||||
t.Fatalf("очищенный фильтр не снят: name=%v remark=%v", d.Name, d.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseNormalizeDropsZeroTimestamps(t *testing.T) {
|
||||
d := BaseDto{StartTime: i64Ptr(0), EndTime: i64Ptr(0)}
|
||||
d.Normalize()
|
||||
|
||||
if d.StartTime != nil || d.EndTime != nil {
|
||||
t.Fatal("нулевая отметка времени означает отсутствие фильтра")
|
||||
}
|
||||
|
||||
kept := BaseDto{StartTime: i64Ptr(1), EndTime: i64Ptr(2)}
|
||||
kept.Normalize()
|
||||
if kept.StartTime == nil || kept.EndTime == nil {
|
||||
t.Fatal("заданные отметки времени потеряны")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogNormalizeDropsZeroLineCount(t *testing.T) {
|
||||
d := LogDto{NumLine: intPtr(0)}
|
||||
d.Normalize()
|
||||
if d.NumLine != nil {
|
||||
t.Fatal("«показать 0 строк» — это пропущенный параметр, а не запрос")
|
||||
}
|
||||
}
|
||||
|
||||
// Все нормализуемые DTO обязаны реализовывать интерфейс: слой контроллеров
|
||||
// вызывает Normalize через него, и забытая реализация означала бы молча
|
||||
// пропущенный шаг.
|
||||
func TestNormalizableIsImplemented(t *testing.T) {
|
||||
var _ Normalizable = (*PeerSaveDto)(nil)
|
||||
var _ Normalizable = (*PeerUpdateDto)(nil)
|
||||
var _ Normalizable = (*PeerPageDto)(nil)
|
||||
var _ Normalizable = (*BaseDto)(nil)
|
||||
var _ Normalizable = (*LogDto)(nil)
|
||||
var _ Normalizable = (*LoginDto)(nil)
|
||||
var _ Normalizable = (*AdminChangePasswordDto)(nil)
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится ни на одной из двух форм.
|
||||
//
|
||||
// Набор его символов не ограничен, пробел по краям — часть значения, и
|
||||
// молчаливое его удаление означало бы, что на сервер уезжает не то, что ввёл
|
||||
// оператор. Логин при этом триммится: скопированное из bootstrap-admin.secret
|
||||
// значение регулярно приезжает с пробелом или переводом строки на конце.
|
||||
func TestPasswordIsNeverTrimmedByNormalization(t *testing.T) {
|
||||
login := LoginDto{Username: strPtr(" hy2xsadmin\n"), Pass: strPtr(" пароль ")}
|
||||
login.Normalize()
|
||||
|
||||
if login.Username == nil || *login.Username != "hy2xsadmin" {
|
||||
t.Fatalf("логин не приведён к каноничному виду: %v", login.Username)
|
||||
}
|
||||
if login.Pass == nil || *login.Pass != " пароль " {
|
||||
t.Fatalf("пароль изменён нормализацией: %q", *login.Pass)
|
||||
}
|
||||
|
||||
change := AdminChangePasswordDto{
|
||||
OldPassword: strPtr("abcde "),
|
||||
NewPassword: strPtr(" abcdef"),
|
||||
}
|
||||
change.Normalize()
|
||||
|
||||
if change.OldPassword == nil || *change.OldPassword != "abcde " {
|
||||
t.Fatalf("старый пароль изменён нормализацией: %v", change.OldPassword)
|
||||
}
|
||||
if change.NewPassword == nil || *change.NewPassword != " abcdef" {
|
||||
t.Fatalf("новый пароль изменён нормализацией: %v", change.NewPassword)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой пароль обязан выглядеть НЕЗАДАННЫМ.
|
||||
//
|
||||
// У поля-указателя `required` считает значение заданным по самому факту
|
||||
// ненулевого указателя, поэтому без этого шага оператор, отправивший пустую
|
||||
// форму, получал отказ по длине вместо «поле обязательно».
|
||||
func TestEmptyPasswordBecomesAbsent(t *testing.T) {
|
||||
login := LoginDto{Username: strPtr("hy2xsadmin"), Pass: strPtr("")}
|
||||
login.Normalize()
|
||||
if login.Pass != nil {
|
||||
t.Fatalf("пустой пароль остался заданным: %q", *login.Pass)
|
||||
}
|
||||
|
||||
change := AdminChangePasswordDto{OldPassword: strPtr(""), NewPassword: strPtr("")}
|
||||
change.Normalize()
|
||||
if change.OldPassword != nil || change.NewPassword != nil {
|
||||
t.Fatal("пустые пароли формы смены остались заданными")
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,85 @@
|
||||
package dto
|
||||
|
||||
// Имя пира проверяется правилом `peerName`, которое несёт и набор символов, и
|
||||
// длину.
|
||||
//
|
||||
// Раньше здесь стояло `min=1,max=32,validateStr`, где `validateStr` требовал
|
||||
// 6-32 символа. Два правила на одном поле противоречили друг другу: имя из
|
||||
// трёх символов проходило `min=1` и отказывалось на `validateStr`, а оператор
|
||||
// видел «invalid» и подсказку «короткий идентификатор пира». Длина живёт
|
||||
// внутри одного правила, чтобы такого расхождения больше не было.
|
||||
|
||||
type PeerPageDto struct {
|
||||
BaseDto
|
||||
Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32"`
|
||||
Name *string `json:"name" form:"name" validate:"omitempty,max=32"`
|
||||
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
|
||||
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"`
|
||||
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
|
||||
}
|
||||
|
||||
// Normalize: очищенный фильтр — это отсутствие фильтра.
|
||||
//
|
||||
// Регрессия, которую это закрывает: `el-input` с крестиком очистки ставит
|
||||
// пустую строку, axios сериализует её как `?name=`, и поиск пиров отказывал с
|
||||
// «invalid» после нажатия на крестик.
|
||||
func (d *PeerPageDto) Normalize() {
|
||||
d.BaseDto.Normalize()
|
||||
blankToNil(&d.Name)
|
||||
blankToNil(&d.Remark)
|
||||
}
|
||||
|
||||
type PeerSaveDto struct {
|
||||
Name *string `json:"name" form:"name" validate:"required,min=1,max=32,validateStr"`
|
||||
Name *string `json:"name" form:"name" validate:"required,peerName"`
|
||||
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
|
||||
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"`
|
||||
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"`
|
||||
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"`
|
||||
Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 1"`
|
||||
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"`
|
||||
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
|
||||
}
|
||||
|
||||
// Normalize: пустой секрет означает «сгенерируй сам».
|
||||
//
|
||||
// Именно это обещает подпись под полем, и именно это умеет CreatePeer. Пустая
|
||||
// пометка при этом остаётся пустой пометкой — «нет комментария» и «не менять
|
||||
// комментарий» не одно и то же.
|
||||
func (d *PeerSaveDto) Normalize() {
|
||||
trimValue(d.Name)
|
||||
blankToNil(&d.Secret)
|
||||
trimValue(d.Remark)
|
||||
}
|
||||
|
||||
type PeerUpdateDto struct {
|
||||
IdDto
|
||||
Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32,validateStr"`
|
||||
// Id приходит из пути `/peers/:id`, а не из тела, поэтому здесь он
|
||||
// НЕОБЯЗАТЕЛЕН.
|
||||
//
|
||||
// Раньше сюда встраивался IdDto с правилом `required,gt=0`, и тело запроса
|
||||
// обязано было повторять идентификатор, уже указанный в адресе. Панель его
|
||||
// повторяла, поэтому расхождение не проявлялось; любой другой клиент,
|
||||
// сделавший PATCH /peers/7 без `"id": 7` в теле, получал отказ «поле id
|
||||
// обязательно» — при том, что значение из тела всё равно затирается
|
||||
// значением из пути.
|
||||
Id *int64 `json:"id" form:"id" validate:"omitempty,gt=0"`
|
||||
Name *string `json:"name" form:"name" validate:"omitempty,peerName"`
|
||||
Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
|
||||
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"`
|
||||
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"`
|
||||
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"`
|
||||
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 1"`
|
||||
Remark *string `json:"remark" form:"remark" validate:"omitempty,min=0,max=64"`
|
||||
Remark *string `json:"remark" form:"remark" validate:"omitempty,max=64"`
|
||||
}
|
||||
|
||||
// Normalize: при изменении пустое имя и пустой секрет означают «не менять».
|
||||
//
|
||||
// Ровно так их и читает service.UpdatePeer (`!= nil && != ""`), поэтому
|
||||
// приведение здесь не добавляет поведения, а убирает расхождение: без него
|
||||
// правила отказывали на входе, который сервис считает законным.
|
||||
//
|
||||
// `remark` и `disabled` намеренно не трогаются: пустая пометка и ноль — это
|
||||
// значения, которые оператор устанавливает осознанно.
|
||||
func (d *PeerUpdateDto) Normalize() {
|
||||
blankToNil(&d.Name)
|
||||
blankToNil(&d.Secret)
|
||||
trimValue(d.Remark)
|
||||
}
|
||||
|
||||
type PeerKickDto struct {
|
||||
|
||||
@@ -31,9 +31,22 @@ type DashboardSystemVo struct {
|
||||
DiskPercent float64 `json:"diskPercent"`
|
||||
}
|
||||
|
||||
// DashboardHysteriaVo — состояние Hysteria из ДВУХ независимых источников.
|
||||
//
|
||||
// `serviceState` отвечает systemd, `apiReachable` — фактическое обращение к
|
||||
// Traffic Stats API. Раньше второе выводилось из первого: сборщик метрик
|
||||
// получал от общего Hysteria2Online пустую карту без ошибки, когда systemctl
|
||||
// говорил «неактивна», и выставлял `apiReachable = true`, ни разу не сходив в
|
||||
// API. Дашборд показывал «служба остановлена» и «API доступен» одновременно.
|
||||
//
|
||||
// `running` сохранён для совместимости отображения и означает ровно
|
||||
// `serviceState == active`. Решения на нём не строятся: «неизвестно» — это не
|
||||
// «остановлена», и путать их продукту уже дорого обходилось.
|
||||
type DashboardHysteriaVo struct {
|
||||
Version string `json:"version"`
|
||||
Running bool `json:"running"`
|
||||
Version string `json:"version"`
|
||||
Running bool `json:"running"`
|
||||
// ServiceState — active | inactive | unknown.
|
||||
ServiceState string `json:"serviceState"`
|
||||
ApiReachable bool `json:"apiReachable"`
|
||||
LastApiError string `json:"lastApiError,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package vo
|
||||
|
||||
// Страница конфигурации показывает ТО, ЧТО НАПИСАНО В ФАЙЛЕ.
|
||||
//
|
||||
// Что было. Панель отдавала браузеру внутреннюю модель серверного конфига
|
||||
// целиком, а frontend накладывал ответ на полный объект значений по умолчанию
|
||||
// (`DeepRequired` + merge). В результате экран отвечал не на вопрос
|
||||
//
|
||||
// что реально написано в /etc/hysteria/config.yaml?
|
||||
//
|
||||
// а на вопрос
|
||||
//
|
||||
// как выглядел бы конфиг, если недостающие куски заполнить дефолтами UI?
|
||||
//
|
||||
// Разница не косметическая. Отсутствующая секция `trafficStats` показывалась
|
||||
// как `:9999`; `speedTest: false` и `disableUDP: false` — валидные явные
|
||||
// значения — считались отсутствующими и прятали свои вкладки;
|
||||
// `ignoreClientBandwidth` (самостоятельная опция верхнего уровня) показывался
|
||||
// только внутри секции bandwidth и при её отсутствии исчезал вместе с ней;
|
||||
// `masquerade.string.statusCode` (число 200..599 у upstream) рисовался
|
||||
// переключателем. То есть экран, существующий ради диагностики расхождений,
|
||||
// эти расхождения скрывал.
|
||||
//
|
||||
// Второе свойство прежнего ответа: он вёз в браузер секреты. `auth` и
|
||||
// `trafficStats.secret` были закрыты `json:"-"`, но пароль обфускации, токены
|
||||
// ACME DNS, учётные данные outbound-прокси и masquerade — нет. Скачиваемый
|
||||
// экспорт того же конфига при этом их вырезает. Читающий экран не имеет права
|
||||
// быть щедрее выгрузки.
|
||||
//
|
||||
// Поэтому ответ описан отдельным типом. Он показывает production-профиль HY2XS
|
||||
// — то, чем реально управляет оркестратор, — и отдельно перечисляет секции,
|
||||
// которых в профиле нет. Полный документ по-прежнему доступен санитизированной
|
||||
// выгрузкой.
|
||||
//
|
||||
// Указатель означает «в файле этого нет». Это единственный способ отличить
|
||||
// отсутствие от значения: `false`, `0` и пустая строка — законные значения.
|
||||
|
||||
// Hysteria2ProfileVo — конфигурация Hysteria в терминах production-профиля.
|
||||
type Hysteria2ProfileVo struct {
|
||||
Listen *string `json:"listen"`
|
||||
Auth *Hysteria2ProfileAuthVo `json:"auth"`
|
||||
Tls *Hysteria2ProfileTlsVo `json:"tls"`
|
||||
Acme *Hysteria2ProfileAcmeVo `json:"acme"`
|
||||
Obfs *Hysteria2ProfileObfsVo `json:"obfs"`
|
||||
Bandwidth *Hysteria2ProfileBandwidthVo `json:"bandwidth"`
|
||||
IgnoreClientBandwidth *bool `json:"ignoreClientBandwidth"`
|
||||
Congestion *Hysteria2ProfileCongestionVo `json:"congestion"`
|
||||
Quic *Hysteria2ProfileQuicVo `json:"quic"`
|
||||
TrafficStats *Hysteria2ProfileTrafficStatsVo `json:"trafficStats"`
|
||||
|
||||
// Drift — секции верхнего уровня, которых production-профиль не описывает.
|
||||
//
|
||||
// Считается по СЫРОМУ YAML, а не по типизированной модели: секция, о
|
||||
// которой HY2XS не знает вовсе, обязана быть замечена именно как
|
||||
// расхождение, а не потеряна при разборе.
|
||||
Drift []string `json:"drift"`
|
||||
}
|
||||
|
||||
// Hysteria2ProfileAuthVo — способ допуска пиров.
|
||||
//
|
||||
// URL показывается санитизированным: это единственный канал допуска, и знать
|
||||
// его порт и путь оператору нужно, а machine token — нет.
|
||||
type Hysteria2ProfileAuthVo struct {
|
||||
Type *string `json:"type"`
|
||||
Url *string `json:"url"`
|
||||
Insecure *bool `json:"insecure"`
|
||||
}
|
||||
|
||||
type Hysteria2ProfileTlsVo struct {
|
||||
Cert *string `json:"cert"`
|
||||
Key *string `json:"key"`
|
||||
SniGuard *string `json:"sniGuard"`
|
||||
ClientCA *string `json:"clientCA"`
|
||||
}
|
||||
|
||||
// Hysteria2ProfileAcmeVo — выпуск сертификата.
|
||||
//
|
||||
// DnsConfigKeys перечисляет ИМЕНА параметров DNS-провайдера без значений: сам
|
||||
// факт «токен задан» диагностичен, а значение — это ключ от DNS-зоны.
|
||||
type Hysteria2ProfileAcmeVo struct {
|
||||
Domains []string `json:"domains"`
|
||||
Email *string `json:"email"`
|
||||
Ca *string `json:"ca"`
|
||||
Dir *string `json:"dir"`
|
||||
ListenHost *string `json:"listenHost"`
|
||||
Type *string `json:"type"`
|
||||
DnsProvider *string `json:"dnsProvider"`
|
||||
DnsConfigKeys []string `json:"dnsConfigKeys"`
|
||||
}
|
||||
|
||||
// Hysteria2ProfileObfsVo — обфускация.
|
||||
//
|
||||
// Пароль не возвращается: он входит в клиентскую ссылку, и оператор получает
|
||||
// его там, где он нужен. Здесь диагностичен только факт, что пароль задан.
|
||||
type Hysteria2ProfileObfsVo struct {
|
||||
Type *string `json:"type"`
|
||||
PasswordSet bool `json:"passwordSet"`
|
||||
MinPacketSize *int `json:"minPacketSize"`
|
||||
MaxPacketSize *int `json:"maxPacketSize"`
|
||||
}
|
||||
|
||||
type Hysteria2ProfileBandwidthVo struct {
|
||||
Up *string `json:"up"`
|
||||
Down *string `json:"down"`
|
||||
DisableLossCompensation *bool `json:"disableLossCompensation"`
|
||||
}
|
||||
|
||||
type Hysteria2ProfileCongestionVo struct {
|
||||
Type *string `json:"type"`
|
||||
BbrProfile *string `json:"bbrProfile"`
|
||||
}
|
||||
|
||||
type Hysteria2ProfileQuicVo struct {
|
||||
InitStreamReceiveWindow *uint64 `json:"initStreamReceiveWindow"`
|
||||
MaxStreamReceiveWindow *uint64 `json:"maxStreamReceiveWindow"`
|
||||
InitConnReceiveWindow *uint64 `json:"initConnReceiveWindow"`
|
||||
MaxConnReceiveWindow *uint64 `json:"maxConnReceiveWindow"`
|
||||
MaxIdleTimeout *string `json:"maxIdleTimeout"`
|
||||
MaxIncomingStreams *int64 `json:"maxIncomingStreams"`
|
||||
DisablePathMTUDiscovery *bool `json:"disablePathMTUDiscovery"`
|
||||
DisableStatelessReset *bool `json:"disableStatelessReset"`
|
||||
}
|
||||
|
||||
// Hysteria2ProfileTrafficStatsVo — внутренний control plane.
|
||||
//
|
||||
// `listen` показывается ровно так, как записан в файле: именно расхождение
|
||||
// этого адреса с loopback выключает лимит устройств, учёт трафика и
|
||||
// принудительное отключение разом, и увидеть его оператор должен здесь.
|
||||
type Hysteria2ProfileTrafficStatsVo struct {
|
||||
Listen *string `json:"listen"`
|
||||
SecretSet bool `json:"secretSet"`
|
||||
}
|
||||
@@ -23,6 +23,14 @@ type LogSystemVo struct {
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// LogHysteria2Vo — строка журнала Hysteria в том виде, в каком её показывает
|
||||
// панель.
|
||||
//
|
||||
// Тип НЕ является формой upstream-записи и никогда не разбирается прямым
|
||||
// json.Unmarshal: JSON-логгер Hysteria 2.12.2 пишет `time` числом
|
||||
// (zapcore.EpochMillisTimeEncoder), и попытка сложить его в строковое поле
|
||||
// роняла разбор целиком. Форма провода живёт в service/journal.go, здесь —
|
||||
// только результат.
|
||||
type LogHysteria2Vo struct {
|
||||
Level string `json:"level"`
|
||||
Msg string `json:"msg"`
|
||||
|
||||
@@ -18,12 +18,44 @@ type PeerVo struct {
|
||||
OnlineDevices int64 `json:"onlineDevices"`
|
||||
}
|
||||
|
||||
// PeerOnlineState — известна ли панели картина подключений прямо сейчас.
|
||||
//
|
||||
// Признак один на всю страницу, а не поле в каждой строке: недоступность
|
||||
// Traffic Stats API — свойство ответа целиком, и nullable-флаг в каждой строке
|
||||
// заставлял бы панель отвечать на этот вопрос заново для каждого пира.
|
||||
const (
|
||||
// PeerOnlineStateOk — Traffic Stats API ответил, `online` в строках
|
||||
// означает то, что написано.
|
||||
PeerOnlineStateOk = "ok"
|
||||
// PeerOnlineStateUnavailable — спросить не удалось. `online = false` в
|
||||
// строках при этом значении не означает НИЧЕГО.
|
||||
PeerOnlineStateUnavailable = "unavailable"
|
||||
)
|
||||
|
||||
// PeerPageVo — страница списка пиров.
|
||||
//
|
||||
// Что было. Список строился так:
|
||||
//
|
||||
// onlineUsers, _ := Hysteria2Online()
|
||||
//
|
||||
// Ошибка отбрасывалась, пустая карта разъезжалась по строкам как `online =
|
||||
// false`, и любой сбой control plane превращался для оператора в утверждение
|
||||
// «все пользователи офлайн» — вместо «состояние подключений сейчас
|
||||
// неизвестно». Это два разных ответа, и первый из них в аварии ведёт искать
|
||||
// проблему у пользователей.
|
||||
type PeerPageVo struct {
|
||||
Records []PeerVo `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
// OnlineState — ok | unavailable.
|
||||
OnlineState string `json:"onlineState"`
|
||||
}
|
||||
|
||||
// PeerClientConfigVo — клиентская ссылка пира.
|
||||
//
|
||||
// Поля QrCode здесь больше нет. Оно было помечено deprecated и возило в
|
||||
// браузер PNG, который панель не использует: QR рисуется во frontend из самой
|
||||
// ссылки (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и
|
||||
// вторым способом получить то же самое.
|
||||
type PeerClientConfigVo struct {
|
||||
Url string `json:"url"`
|
||||
QrCode []byte `json:"qrCode,omitempty"` // deprecated: frontend renders SVG QR from Url
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
package vo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FieldError — одна причина отказа.
|
||||
//
|
||||
// `Field` заполняется, когда причина относится к конкретному полю формы, и
|
||||
// пуст для отказов уровня операции. `Params` несёт числа правила (границы
|
||||
// длины, допустимые значения), чтобы панель могла составить точную фразу, не
|
||||
// заводя у себя вторую копию этих чисел.
|
||||
type FieldError struct {
|
||||
Code string `json:"code"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type result struct {
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Errors []FieldError `json:"errors,omitempty"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -26,21 +41,68 @@ func Success(data interface{}, c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func Fail(message string, c *gin.Context) {
|
||||
var code int
|
||||
if constant.UnauthorizedError == message {
|
||||
code = constant.CodeUnauthorizedError
|
||||
} else if constant.ForbiddenError == message {
|
||||
code = constant.CodeForbiddenError
|
||||
} else if constant.InvalidError == message {
|
||||
code = constant.CodeInvalidError
|
||||
} else {
|
||||
code = constant.CodeSysError
|
||||
}
|
||||
// FailWith — единственное место, где формируется ответ об ошибке.
|
||||
//
|
||||
// Код передаётся аргументом. Раньше он ВЫВОДИЛСЯ здесь сравнением текста
|
||||
// сообщения с тремя известными строками:
|
||||
//
|
||||
// if constant.UnauthorizedError == message { code = ... }
|
||||
//
|
||||
// Это тот же антипаттерн, который запрещён панели, только на сервере: смысл
|
||||
// ответа определялся совпадением литерала. Следствие было не теоретическим —
|
||||
// истёкший токен возвращал `token expired`, под условия не подходил и уезжал
|
||||
// как обычная системная ошибка с кодом 50000. Панель показывала оператору
|
||||
// голый тост и не понимала, что сессия кончилась: ветка входа заново не
|
||||
// срабатывала никогда.
|
||||
func FailWith(code int, message string, fieldErrors []FieldError, c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result{
|
||||
Code: code,
|
||||
Type: TypeError,
|
||||
Message: message,
|
||||
Errors: fieldErrors,
|
||||
Data: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// Fail — отказ уровня операции: правила соблюдены, выполнить нельзя.
|
||||
func Fail(message string, c *gin.Context) {
|
||||
FailWith(constant.CodeSysError, message, nil, c)
|
||||
}
|
||||
|
||||
// FailDomain — тот же отказ, но с машиночитаемым кодом причины.
|
||||
func FailDomain(code string, message string, c *gin.Context) {
|
||||
FailWith(constant.CodeSysError, message, []FieldError{{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}}, c)
|
||||
}
|
||||
|
||||
// FailField — отказ уровня операции, привязанный к полю формы.
|
||||
func FailField(code string, field string, message string, c *gin.Context) {
|
||||
FailWith(constant.CodeSysError, message, []FieldError{{
|
||||
Code: code,
|
||||
Field: field,
|
||||
Message: message,
|
||||
}}, c)
|
||||
}
|
||||
|
||||
// FailValidation — вход не прошёл проверку правил.
|
||||
func FailValidation(message string, fieldErrors []FieldError, c *gin.Context) {
|
||||
FailWith(constant.CodeInvalidError, message, fieldErrors, c)
|
||||
}
|
||||
|
||||
// FailUnauthorized — вход требуется или сессия больше не действует.
|
||||
//
|
||||
// Причина передаётся кодом: панель по-разному ведёт себя, когда токена нет
|
||||
// вовсе и когда он только что истёк под руками у оператора.
|
||||
func FailUnauthorized(code string, message string, c *gin.Context) {
|
||||
FailWith(constant.CodeUnauthorizedError, message, []FieldError{{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}}, c)
|
||||
}
|
||||
|
||||
// FailForbidden — вход выполнен, но прав недостаточно.
|
||||
func FailForbidden(message string, c *gin.Context) {
|
||||
FailWith(constant.CodeForbiddenError, message, nil, c)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,32 @@ type Hysteria2Api struct {
|
||||
apiPort int64
|
||||
}
|
||||
|
||||
// describeFailure объясняет, ПОЧЕМУ обращение к Traffic Stats API не удалось.
|
||||
//
|
||||
// Отказ здесь бывает двух видов, и раньше журнал различал их ровно наоборот
|
||||
// тому, как нужно. Запись делалась так:
|
||||
//
|
||||
// if err != nil || resp.StatusCode != http.StatusOK {
|
||||
// logrus.Errorf("Hysteria2 KickUsers err: %v", err)
|
||||
//
|
||||
// то есть при отказе САМОГО API (200 не пришёл, а транспорт сработал) в журнал
|
||||
// уезжало `err: <nil>` — сообщение без единого факта именно в том случае,
|
||||
// когда Hysteria что-то ответила и её ответ был бы полезен. Транспортная
|
||||
// ошибка при этом печаталась нормально, и внешне обе выглядели одинаково.
|
||||
//
|
||||
// Различать их стало обязательно: на этом пути теперь стоит отзыв доступа, и
|
||||
// «сессию разорвать не удалось» — единственный след, по которому оператор
|
||||
// поймёт, почему отключённый пир остался на связи.
|
||||
func describeFailure(err error, resp *http.Response) string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if resp != nil {
|
||||
return fmt.Sprintf("unexpected status %s", resp.Status)
|
||||
}
|
||||
return "no response"
|
||||
}
|
||||
|
||||
func NewHysteria2Api(apiPort int64) *Hysteria2Api {
|
||||
return &Hysteria2Api{
|
||||
apiPort: apiPort,
|
||||
@@ -46,7 +72,7 @@ func (h *Hysteria2Api) ListUsers(clear bool, secret string) (map[string]bo.Hyste
|
||||
}
|
||||
}()
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
logrus.Errorf("Hysteria2 ListUsers err: %v", err)
|
||||
logrus.Errorf("Hysteria2 ListUsers failed: %s", describeFailure(err, resp))
|
||||
return nil, errors.New("http connection error")
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@@ -86,7 +112,7 @@ func (h *Hysteria2Api) KickUsers(keys []string, secret string) error {
|
||||
}
|
||||
}()
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
logrus.Errorf("Hysteria2 KickUsers err: %v", err)
|
||||
logrus.Errorf("Hysteria2 KickUsers failed: %s", describeFailure(err, resp))
|
||||
return errors.New("http connection error")
|
||||
}
|
||||
return nil
|
||||
@@ -111,7 +137,7 @@ func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) {
|
||||
}
|
||||
}()
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
logrus.Errorf("Hysteria2 OnlineUsers err: %v", err)
|
||||
logrus.Errorf("Hysteria2 OnlineUsers failed: %s", describeFailure(err, resp))
|
||||
return nil, errors.New("http connection error")
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
@@ -25,13 +26,29 @@ func adminClaimsFromContext(c *gin.Context) (bo.AccountBo, bool) {
|
||||
return claims, castOK
|
||||
}
|
||||
|
||||
// ErrInvalidCredentials — логин или пароль не подошли.
|
||||
//
|
||||
// ОДНО значение на оба случая, и это не упрощение. «Такого администратора
|
||||
// нет» и «пароль не тот» обязаны быть неразличимы снаружи: иначе форма входа
|
||||
// превращается в способ проверять существование имён администраторов, а
|
||||
// панель слушает только localhost именно потому, что вход — самая ценная
|
||||
// дверь продукта.
|
||||
//
|
||||
// Отказ хранилища при этом сюда НЕ сворачивается: слой данных уже умеет
|
||||
// отличать «записи нет» от «база не ответила» (dao.IsNotFound), и недоступная
|
||||
// SQLite обязана выглядеть как системная ошибка, а не как неверный пароль.
|
||||
var ErrInvalidCredentials = errors.New(constant.WrongPassword)
|
||||
|
||||
func Login(username string, plainPassword string) (string, bool, error) {
|
||||
admin, err := dao.GetAdminUser("username = ? and status = 1", username)
|
||||
if err != nil {
|
||||
if dao.IsNotFound(err) {
|
||||
return "", false, ErrInvalidCredentials
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
|
||||
return "", false, errors.New(constant.WrongPassword)
|
||||
return "", false, ErrInvalidCredentials
|
||||
}
|
||||
tokenVersion := int64(1)
|
||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
||||
@@ -42,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
||||
if tokenErr != nil {
|
||||
return "", false, tokenErr
|
||||
}
|
||||
// Отметка о входе ставится здесь, а не в слое контроллеров.
|
||||
//
|
||||
// Колонка `last_login_at` объявлена и в схеме, и в entity, а писать её было
|
||||
// некому: UpdateAdminLastLoginAt существовал и не вызывался ниоткуда, то
|
||||
// есть панель показывала бы «последний вход» как 1970 год на живом сервере.
|
||||
// Место выбрано так, чтобы записать её было невозможно забыть: успешная
|
||||
// проверка пароля — единственная дверь, и она здесь одна.
|
||||
//
|
||||
// Отказ записи НЕ отменяет вход. Учётные данные уже подтверждены, и
|
||||
// превращать неудачу бухгалтерии в отказ аутентификации значило бы закрыть
|
||||
// панель из-за строки статистики. Молчать при этом тоже нельзя: неписаная
|
||||
// отметка — расхождение между тем, что показывает панель, и тем, что
|
||||
// произошло.
|
||||
if err = UpdateAdminLastLoginAt(*admin.Id, time.Now().UnixMilli()); err != nil {
|
||||
logrus.Errorf("не удалось записать время входа администратора %d: %v", *admin.Id, err)
|
||||
}
|
||||
|
||||
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
||||
// с поддержкой самого формата: единственный источник этого требования —
|
||||
// флаг force_password_change, который ставит установка.
|
||||
|
||||
@@ -117,21 +117,79 @@ func GetHysteria2ApiPort() (int64, error) {
|
||||
return apiPort, nil
|
||||
}
|
||||
|
||||
// Адрес Traffic Stats API — ОДИН контракт на обе половины продукта.
|
||||
//
|
||||
// Что было. Отсюда доставался только порт, а хост молча отбрасывался: слой
|
||||
// proxy в любом случае собирает `http://127.0.0.1:<порт>`. Пока оркестратор
|
||||
// разрешал в `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST` произвольный IPv4, это давало
|
||||
// конфигурацию, при которой Hysteria слушает один адрес, а админка стучится в
|
||||
// другой — и весь контур доступа выключался fail-closed: `/online` недоступен,
|
||||
// значит лимит устройств проверить нельзя, значит ни один пир не пускается.
|
||||
//
|
||||
// Оркестратор теперь фиксирует адрес на `127.0.0.1`, но эта функция читает
|
||||
// ФАЙЛ, а файл может разойтись с оркестратором: правка руками, восстановление
|
||||
// чужой копии, ручной эксперимент. Поэтому расхождение называется здесь, в
|
||||
// первой же точке контакта, а не проявляется отказом соединения без объяснения.
|
||||
//
|
||||
// Молча подставлять loopback вместо прочитанного хоста нельзя ровно потому, что
|
||||
// это и есть тот самый второй контракт: значение, которое админка приняла и
|
||||
// проигнорировала.
|
||||
//
|
||||
// Вопрос здесь ровно один: ДОСТУЧИТСЯ ли админка по этому адресу. Соответствие
|
||||
// production-профилю — вопрос другой и решается там, где конфиг создаётся
|
||||
// (validateRuntimeConfig) и где он показывается оператору (страница
|
||||
// конфигурации отдельно называет wildcard и не-канонический loopback).
|
||||
//
|
||||
// Что принимается:
|
||||
//
|
||||
// "127.0.0.1:36712" -> канонический production-профиль;
|
||||
// "0.0.0.0:36712" -> wildcard принимает соединение на 127.0.0.1;
|
||||
// ":36712" -> тот же wildcard: пустой хост в Go означает ВСЕ
|
||||
// интерфейсы, а не loopback.
|
||||
//
|
||||
// Wildcard принимается именно потому, что связь между компонентами при нём
|
||||
// РАБОТАЕТ: отказывать в обслуживании из-за лишней публикации внутреннего API
|
||||
// значило бы наказывать за неё отключением всех пиров. Сама лишняя публикация
|
||||
// при этом не остаётся незамеченной — о ней сообщает панель.
|
||||
//
|
||||
// Прочие адреса loopback НЕ принимаются, и это не педантизм. Слушатель,
|
||||
// привязанный к конкретному адресу, принимает соединения только на него:
|
||||
//
|
||||
// bind 127.0.0.5:36712 -> dial 127.0.0.1:36712 -> connection refused
|
||||
//
|
||||
// (проверено). То есть `ip.IsLoopback()` здесь слишком широк: `127.0.0.5`
|
||||
// выглядит «локальным», но control plane при нём уже не работает, а молчаливое
|
||||
// принятие такого адреса означало бы отказ авторизации всем пирам без единой
|
||||
// подсказки о причине.
|
||||
//
|
||||
// IPv6-wildcard (`[::]:36712`) тоже не принимается, хотя на хосте с
|
||||
// `net.ipv6.bindv6only=0` соединение на 127.0.0.1 он принимает. Причин две:
|
||||
// HY2XS объявлен IPv4-only (`HY2XS_IPV6_ENABLED` обязан быть false), и
|
||||
// достижимость такого слушателя зависит от настройки ядра, которой продукт не
|
||||
// управляет. Отвечать «достучусь» на основании чужого sysctl нельзя, а
|
||||
// указанное в отказе действие — `reconfigure` — для этой конфигурации всё равно
|
||||
// верное.
|
||||
func parseTrafficStatsPort(listen string) (int64, error) {
|
||||
trimmed := strings.TrimSpace(listen)
|
||||
if trimmed == "" {
|
||||
return 0, errors.New("empty listen")
|
||||
}
|
||||
|
||||
// Пустой хост подставляется только затем, чтобы SplitHostPort принял
|
||||
// строку: сам по себе `:36712` означает все интерфейсы, и loopback среди
|
||||
// них, поэтому подстановка не меняет ответа на вопрос о достижимости.
|
||||
hostPort := trimmed
|
||||
if strings.HasPrefix(trimmed, ":") {
|
||||
hostPort = "127.0.0.1" + trimmed
|
||||
}
|
||||
|
||||
_, portStr, err := net.SplitHostPort(hostPort)
|
||||
host, portStr, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := assertTrafficStatsHostReachable(host); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port, err := strconv.ParseInt(portStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -141,3 +199,24 @@ func parseTrafficStatsPort(listen string) (int64, error) {
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// assertTrafficStatsHostReachable отвечает, сможет ли админка попасть по этому
|
||||
// адресу — а не «нравится ли он профилю».
|
||||
//
|
||||
// Совпадение точное, без `ip.IsLoopback()`: слушатель на конкретном адресе
|
||||
// принимает соединения только на него, поэтому «какой-нибудь loopback» и
|
||||
// «достижим по 127.0.0.1» — разные утверждения. Разбор — в комментарии выше.
|
||||
func assertTrafficStatsHostReachable(host string) error {
|
||||
switch strings.TrimSpace(host) {
|
||||
case "", "127.0.0.1", "0.0.0.0":
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"trafficStats.listen слушает %s, а админка обращается к Traffic Stats API строго через 127.0.0.1: "+
|
||||
"слушатель на конкретном адресе соединения на 127.0.0.1 не принимает. "+
|
||||
"Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают. "+
|
||||
"Верните 127.0.0.1 через `hy2xs-orchestrator reconfigure`",
|
||||
strings.TrimSpace(host),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hy2xs-admin/model/constant"
|
||||
)
|
||||
|
||||
// Адрес Traffic Stats API — межкомпонентный контракт, и админка обязана
|
||||
// заметить его нарушение, а не работать «как будто там loopback».
|
||||
//
|
||||
// Ломается это одной строкой в `/etc/hysteria/config.yaml`: Hysteria начинает
|
||||
// слушать другой адрес, админка продолжает стучаться в 127.0.0.1, и весь контур
|
||||
// доступа выключается fail-closed — ни один пир не проходит авторизацию, потому
|
||||
// что лимит устройств проверить нечем. Отказ обязан называть причину.
|
||||
|
||||
func TestParseTrafficStatsPortAcceptsReachableAddresses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
listen string
|
||||
want int64
|
||||
}{
|
||||
{"канонический профиль", "127.0.0.1:36712", 36712},
|
||||
{"пробелы по краям", " 127.0.0.1:36712 ", 36712},
|
||||
// Wildcard принимает соединение на 127.0.0.1: обмен состоится, и
|
||||
// отказывать в обслуживании из-за лишней публикации внутреннего API
|
||||
// нельзя — это отключило бы всех пиров. Запрет на wildcard принадлежит
|
||||
// оркестратору, который конфиг и создаёт, а сообщает о нём панель.
|
||||
{"wildcard", "0.0.0.0:36712", 36712},
|
||||
// Пустой хост — тот же wildcard, а не loopback: в Go `:36712`
|
||||
// означает все интерфейсы.
|
||||
{"хост не задан", ":36712", 36712},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseTrafficStatsPort(tc.listen)
|
||||
if err != nil {
|
||||
t.Fatalf("адрес %q отклонён: %v", tc.listen, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("порт %d, ожидался %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrafficStatsPortRejectsUnreachableAddresses(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
listen string
|
||||
}{
|
||||
{"адрес LAN", "192.168.1.10:36712"},
|
||||
{"публичный адрес", "203.0.113.10:36712"},
|
||||
{"имя хоста", "hysteria.internal:36712"},
|
||||
// Главный случай, ради которого проверка стала точной. Слушатель на
|
||||
// конкретном адресе принимает соединения только на него:
|
||||
//
|
||||
// bind 127.0.0.5:36712 -> dial 127.0.0.1:36712 -> connection refused
|
||||
//
|
||||
// «Какой-нибудь loopback» и «достижим по 127.0.0.1» — разные
|
||||
// утверждения, и прежний `ip.IsLoopback()` их смешивал: control plane
|
||||
// при таком адресе уже не работает, а админка молчала.
|
||||
{"другой адрес loopback", "127.0.0.5:36712"},
|
||||
{"loopback-подсеть целиком", "127.1.2.3:36712"},
|
||||
// IPv6-wildcard на хосте с bindv6only=0 соединение принял бы, но HY2XS
|
||||
// объявлен IPv4-only, а зависеть в ответе «достучусь» от чужого sysctl
|
||||
// нельзя.
|
||||
{"IPv6 wildcard", "[::]:36712"},
|
||||
{"пустая строка", ""},
|
||||
{"порт вне диапазона", "127.0.0.1:70000"},
|
||||
{"без порта", "127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := parseTrafficStatsPort(tc.listen); err == nil {
|
||||
t.Fatalf("недостижимый адрес %q принят", tc.listen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ обязан называть и адрес, и способ починки: оператор, получивший
|
||||
// «connection refused» без объяснения, ищет проблему в Hysteria.
|
||||
func TestGetHysteria2ApiPortExplainsNonLoopbackListen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := "listen: \":443\"\ntrafficStats:\n listen: \"192.168.1.10:36712\"\n secret: \"s\"\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("не удалось записать тестовый конфиг: %v", err)
|
||||
}
|
||||
previous := constant.Hysteria2ConfigPath
|
||||
constant.Hysteria2ConfigPath = path
|
||||
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
|
||||
|
||||
_, err := GetHysteria2ApiPort()
|
||||
if err == nil {
|
||||
t.Fatal("админка приняла адрес, по которому не может обратиться")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "192.168.1.10") {
|
||||
t.Errorf("сообщение не называет фактический адрес: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reconfigure") {
|
||||
t.Errorf("сообщение не называет способ починки: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Свойство, на котором держится точность проверки, зафиксировано прямо здесь:
|
||||
// слушатель на конкретном адресе loopback НЕ принимает соединения на
|
||||
// 127.0.0.1, а wildcard принимает.
|
||||
//
|
||||
// Без этого теста правило «принимаем только 127.0.0.1 и 0.0.0.0» выглядит
|
||||
// произвольным ужесточением, и следующий читатель имеет все основания вернуть
|
||||
// `ip.IsLoopback()` обратно.
|
||||
func TestLoopbackBindIsNotReachableThroughAnotherLoopbackAddress(t *testing.T) {
|
||||
specific, err := net.Listen("tcp", "127.0.0.5:0")
|
||||
if err != nil {
|
||||
t.Skipf("хост не позволяет привязаться к 127.0.0.5: %v", err)
|
||||
}
|
||||
defer specific.Close()
|
||||
|
||||
_, port, err := net.SplitHostPort(specific.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("адрес слушателя не разобран: %v", err)
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", port), 2*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("соединение на 127.0.0.1 принято слушателем, привязанным к 127.0.0.5: " +
|
||||
"правило проверки адреса построено на неверном допущении")
|
||||
}
|
||||
|
||||
// Обратная половина: wildcard такое соединение принимает, поэтому
|
||||
// отказывать по нему в обслуживании было бы неверно.
|
||||
wildcard, err := net.Listen("tcp", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось занять wildcard-порт: %v", err)
|
||||
}
|
||||
defer wildcard.Close()
|
||||
go func() {
|
||||
accepted, acceptErr := wildcard.Accept()
|
||||
if acceptErr == nil {
|
||||
accepted.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
_, wildcardPort, err := net.SplitHostPort(wildcard.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("адрес wildcard-слушателя не разобран: %v", err)
|
||||
}
|
||||
conn, err = net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", wildcardPort), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("wildcard не принял соединение на 127.0.0.1: %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
@@ -1,78 +1,168 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/proxy"
|
||||
"hy2xs-admin/util"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var trafficMutex sync.Mutex
|
||||
var kickMutex sync.Mutex
|
||||
// Джоба учёта принадлежит планировщику, а не собственным горутинам.
|
||||
//
|
||||
// Что было:
|
||||
//
|
||||
// CronHandleAccount()
|
||||
// -> go func()
|
||||
// -> go saveAccountTraffic()
|
||||
// -> go kickAccount()
|
||||
//
|
||||
// Три уровня отсоединённых горутин. Для cron.Cron джоба заканчивалась почти
|
||||
// мгновенно — сразу после запуска внешней, — поэтому StopCron(), который
|
||||
// честно ждёт `scheduler.Stop().Done()`, не ждал НИЧЕГО из настоящей работы.
|
||||
// Завершение процесса выглядело так: планировщик отчитался «джоб не осталось»,
|
||||
// releaseResource() закрыл SQLite, а внутренние горутины продолжали писать
|
||||
// трафик и рвать сессии в уже закрытое соединение. Это ровно та болезнь, от
|
||||
// которой лечится cron_scheduler.go, только протащенная внутрь одной джобы.
|
||||
//
|
||||
// Второе следствие того же устройства было тише и хуже. Обе внутренние
|
||||
// горутины запускались ПАРАЛЛЕЛЬНО, поэтому принудительное отключение читало
|
||||
// счётчики трафика ДО того, как в них попадала только что снятая дельта. При
|
||||
// тридцатисекундном тике это значит, что превышение квоты замечалось в лучшем
|
||||
// случае со следующего цикла, а на границе — не замечалось вовсе.
|
||||
//
|
||||
// Теперь джоба синхронна, порядок внутри неё строгий, а взаимное исключение
|
||||
// даёт один мьютекс на весь цикл: сбор трафика и enforcement больше не могут
|
||||
// ни разъехаться во времени, ни наложиться сами на себя.
|
||||
|
||||
// accountJobMutex сериализует цикл учёта.
|
||||
//
|
||||
// Заменяет пару trafficMutex + kickMutex. Раздельные мьютексы защищали каждую
|
||||
// половину от самой себя, но не защищали пару от расщепления: при затянувшемся
|
||||
// сборе трафика следующий тик мог запустить enforcement поверх предыдущего
|
||||
// сбора. Одного мьютекса на весь цикл достаточно и, в отличие от двух, он
|
||||
// выражает действительный инвариант — «в любой момент времени выполняется не
|
||||
// более одного цикла учёта».
|
||||
var accountJobMutex sync.Mutex
|
||||
|
||||
// CronHandleAccount — один синхронный цикл учёта: собрать трафик, затем
|
||||
// применить политику доступа.
|
||||
//
|
||||
// Состояние службы по systemd здесь НЕ спрашивается. Прежний гейт
|
||||
//
|
||||
// if !Hysteria2IsRunning() { return }
|
||||
//
|
||||
// стоял на решении о применении операции, а Hysteria2IsRunning для этого
|
||||
// непригоден по собственному объявлению: util.Exec схлопывает «systemctl
|
||||
// вернул 3, служба неактивна» и «запустить systemctl не удалось» в одну
|
||||
// ошибку. То есть сломанный systemctl при живой Hysteria молча отключал и учёт
|
||||
// трафика, и принудительное отключение — без единой строки в журнале.
|
||||
//
|
||||
// Нужные системы спрашиваются напрямую: `/traffic`, `/online`, `/kick`. Если
|
||||
// Hysteria действительно не работает, вызов вернёт ошибку, и она будет
|
||||
// записана. Если сломан systemctl, а Hysteria жива, учёт продолжит работать.
|
||||
func CronHandleAccount() {
|
||||
go func() {
|
||||
if !Hysteria2IsRunning() {
|
||||
return
|
||||
}
|
||||
// Пропуск тика при уже идущем цикле — не отказ: следующий тик через 30
|
||||
// секунд, а очередь из накопившихся циклов ничего бы не дала.
|
||||
if !accountJobMutex.TryLock() {
|
||||
return
|
||||
}
|
||||
defer accountJobMutex.Unlock()
|
||||
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("cron: не удалось определить порт Traffic Stats API; цикл учёта пропущен")
|
||||
return
|
||||
}
|
||||
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Секрет берётся общей функцией, которая отличает «ключа нет» от пустого
|
||||
// значения. Раньше здесь стояло `*trafficSecretConfig.Value` без единой
|
||||
// проверки: строка в таблице `config` без значения роняла бы процесс
|
||||
// паникой на разыменовании nil — причём внутри отсоединённой горутины, где
|
||||
// её некому перехватить, то есть падал бы весь сервис вместе с
|
||||
// обработчиком machine-auth.
|
||||
secret, err := hysteria2TrafficSecret()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("cron: секрет Traffic Stats API недоступен; цикл учёта пропущен")
|
||||
return
|
||||
}
|
||||
|
||||
// Сохранение данных трафика
|
||||
go saveAccountTraffic(apiPort, *trafficSecretConfig.Value)
|
||||
// Порядок обязателен: enforcement принимает решение по счётчикам, поэтому
|
||||
// счётчики должны быть уже обновлены.
|
||||
if err := saveAccountTraffic(apiPort, secret); err != nil {
|
||||
logrus.WithError(err).Error("cron: сбор трафика завершился с ошибкой")
|
||||
}
|
||||
|
||||
// Принудительное отключение
|
||||
go kickAccount(apiPort, *trafficSecretConfig.Value)
|
||||
}()
|
||||
if err := enforcePeerAccess(apiPort, secret); err != nil {
|
||||
logrus.WithError(err).Error("cron: принудительное отключение завершилось с ошибкой")
|
||||
}
|
||||
}
|
||||
|
||||
// CronResetTraffic обнуляет счётчики трафика всех пиров по расписанию.
|
||||
func CronResetTraffic() {
|
||||
peers, err := dao.ListPeer("1=1")
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("cron: не удалось прочитать пиров для сброса трафика")
|
||||
return
|
||||
}
|
||||
var ids []int64
|
||||
ids := make([]int64, 0, len(peers))
|
||||
for _, item := range peers {
|
||||
// Строка без идентификатора — повреждённые данные. Раньше здесь
|
||||
// стояло `*item.Id` без проверки, то есть такая строка роняла джобу
|
||||
// паникой, а вместе с ней и процесс.
|
||||
if item.Id == nil {
|
||||
logrus.Error("cron: строка пира без идентификатора пропущена при сбросе трафика")
|
||||
continue
|
||||
}
|
||||
ids = append(ids, *item.Id)
|
||||
}
|
||||
idsList := util.SplitArr(ids, 100)
|
||||
for _, item := range idsList {
|
||||
if err := dao.UpdatePeer(item, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}); err != nil {
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
for _, chunk := range util.SplitArr(ids, 100) {
|
||||
if err := dao.UpdatePeer(chunk, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}); err != nil {
|
||||
logrus.WithError(err).Error("cron: сброс трафика части пиров не выполнен")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveAccountTraffic(apiPort int64, trafficStatsSecret string) {
|
||||
if !trafficMutex.TryLock() {
|
||||
return
|
||||
}
|
||||
defer trafficMutex.Unlock()
|
||||
|
||||
// saveAccountTraffic переносит накопленный Hysteria трафик в базу.
|
||||
//
|
||||
// Чтение ДЕСТРУКТИВНОЕ: `?clear=1` обнуляет счётчики Hysteria сразу после
|
||||
// того, как ответ отправлен (официальный контракт Traffic Stats API). Значит
|
||||
// каждая дельта существует ровно в одном экземпляре, и потерянная здесь
|
||||
// потеряна навсегда.
|
||||
//
|
||||
// Полностью закрыть это окно можно только сменой модели учёта — недеструктивным
|
||||
// `GET /traffic` с долговременными checkpoint'ами верхних счётчиков и
|
||||
// вычислением дельты на стороне админки. Это отдельная подсистема с обработкой
|
||||
// перезапуска и сброса счётчиков Hysteria, и в текущем проходе она намеренно
|
||||
// не вводится: квота здесь — операционная граница доступа, а не биллинговый
|
||||
// учёт с финансово значимым каждым байтом.
|
||||
//
|
||||
// Чего это НЕ оправдывает — молчания. Раньше отказ записи внутри цикла делал
|
||||
// `continue`, и дельта конкретного пира исчезала, не оставив следа в исходе
|
||||
// джобы. Теперь каждая потеря считается и попадает в возвращаемую ошибку.
|
||||
func saveAccountTraffic(apiPort int64, trafficStatsSecret string) error {
|
||||
users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, trafficStatsSecret)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
hourStart := nowMs - (nowMs % int64(time.Hour/time.Millisecond))
|
||||
|
||||
lost := 0
|
||||
for key, traffic := range users {
|
||||
rxBytes := traffic.Rx
|
||||
txBytes := traffic.Tx
|
||||
@@ -82,9 +172,19 @@ func saveAccountTraffic(apiPort int64, trafficStatsSecret string) {
|
||||
|
||||
peer, peerErr := dao.GetPeer("auth_id = ?", key)
|
||||
if peerErr != nil {
|
||||
// Пир, которого админка не знает: удалён между сбором и записью
|
||||
// либо создан в обход панели. Дельта уже обнулена в Hysteria и
|
||||
// приписывать её некому.
|
||||
logrus.WithError(peerErr).
|
||||
WithField("authId", key).
|
||||
Warn("cron: трафик получен для неизвестного пира и не записан")
|
||||
lost++
|
||||
continue
|
||||
}
|
||||
if peer.Id == nil {
|
||||
logrus.WithField("authId", key).
|
||||
Error("cron: строка пира без идентификатора; трафик не записан")
|
||||
lost++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -100,66 +200,151 @@ func saveAccountTraffic(apiPort int64, trafficStatsSecret string) {
|
||||
TxBytes: &txBytes,
|
||||
SampledAt: &nowMs,
|
||||
}
|
||||
if err = dao.SaveTrafficSample(sample); err != nil {
|
||||
logrus.Errorf("save traffic_sample failed: %v", err)
|
||||
continue
|
||||
if err := dao.SaveTrafficSample(sample); err != nil {
|
||||
logrus.WithError(err).
|
||||
WithField("peerId", *peer.Id).
|
||||
Error("cron: не удалось сохранить отсчёт трафика")
|
||||
// Отсчёт — история для графиков; счётчики пира важнее, и попытка
|
||||
// их обновить продолжается.
|
||||
}
|
||||
|
||||
if err = dao.UpdatePeer([]int64{*peer.Id}, map[string]interface{}{
|
||||
if err := dao.UpdatePeer([]int64{*peer.Id}, map[string]interface{}{
|
||||
"download_bytes": gorm.Expr("download_bytes + ?", rxBytes),
|
||||
"upload_bytes": gorm.Expr("upload_bytes + ?", txBytes),
|
||||
}); err != nil {
|
||||
logrus.Errorf("update peer traffic failed: %v", err)
|
||||
logrus.WithError(err).
|
||||
WithField("peerId", *peer.Id).
|
||||
Error("cron: счётчики пира не обновлены; дельта Hysteria уже обнулена и потеряна")
|
||||
lost++
|
||||
continue
|
||||
}
|
||||
|
||||
_ = dao.UpsertTrafficAggregateHourly(*peer.Id, hourStart, rxBytes, txBytes)
|
||||
}
|
||||
}
|
||||
func kickAccount(apiPort int64, trafficStatsSecret string) {
|
||||
if !kickMutex.TryLock() {
|
||||
return
|
||||
}
|
||||
defer kickMutex.Unlock()
|
||||
|
||||
users, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(trafficStatsSecret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(users) > 0 {
|
||||
i := 0
|
||||
authIDs := make([]string, len(users))
|
||||
for k := range users {
|
||||
authIDs[i] = k
|
||||
i++
|
||||
}
|
||||
authIDLists := util.SplitArr(authIDs, 10)
|
||||
var wg sync.WaitGroup
|
||||
for _, authIDList := range authIDLists {
|
||||
wg.Add(1)
|
||||
go func(authIDList []string) {
|
||||
defer wg.Done()
|
||||
now := time.Now().UnixMilli()
|
||||
peers, err := dao.ListPeer(`auth_id in ? and (
|
||||
disabled = 1
|
||||
or (quota_bytes > 0 and quota_bytes < download_bytes + upload_bytes)
|
||||
or (expires_at > 0 and ? > expires_at)
|
||||
or ? < banned_until
|
||||
)`, authIDList, now, now)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
kickAuthIDs := make([]string, len(peers))
|
||||
j := 0
|
||||
for _, item := range peers {
|
||||
kickAuthIDs[j] = *item.AuthId
|
||||
j++
|
||||
}
|
||||
if err = proxy.NewHysteria2Api(apiPort).KickUsers(kickAuthIDs, trafficStatsSecret); err != nil {
|
||||
return
|
||||
}
|
||||
}(authIDList)
|
||||
}
|
||||
wg.Wait()
|
||||
if lost > 0 {
|
||||
return &trafficLossError{lost: lost}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// trafficLossError сообщает, сколько дельт не удалось записать.
|
||||
//
|
||||
// Отдельный тип, а не fmt.Errorf, потому что количество здесь — величина, а не
|
||||
// украшение фразы: чтение `?clear=1` деструктивно, поэтому «потеряно 1 из 200»
|
||||
// и «потеряно 200 из 200» — разные события, и различать их должен уметь не
|
||||
// только человек, читающий журнал.
|
||||
type trafficLossError struct{ lost int }
|
||||
|
||||
func (e *trafficLossError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"дельт трафика не записано и потеряно безвозвратно: %d",
|
||||
e.lost,
|
||||
)
|
||||
}
|
||||
|
||||
// enforcePeerAccess приводит ЖИВЫЕ СЕССИИ в соответствие с сохранённым
|
||||
// состоянием.
|
||||
//
|
||||
// Политика берётся из peerAccessDenied — той же функции, по которой пира
|
||||
// пускает или не пускает авторизация. Собственного SQL-условия здесь больше
|
||||
// нет, и это главное свойство: пока правило было записано в двух местах, оно
|
||||
// расходилось на границах quota, expiry и ban, и исчерпавший квоту пир не
|
||||
// пускался заново, но и не отключался никогда.
|
||||
//
|
||||
// Обход идёт по КАЖДОМУ authID, который Hysteria считает живым, а не по
|
||||
// найденным в базе пирам. Прежняя реализация читала
|
||||
//
|
||||
// peers, err := dao.ListPeer("auth_id in ?", chunk)
|
||||
// for _, peer := range peers { ... }
|
||||
//
|
||||
// и потому не видела сессий, которым в базе больше ничего не соответствует.
|
||||
// Это не теоретический случай: `auth_id` перезаписывает импорт, а строку
|
||||
// целиком убирает удаление. Обе операции рвут старую сессию сами, но их второй
|
||||
// шаг может не удаться — и тогда единственным местом, где о ней ещё известно,
|
||||
// остаётся сам `/online`. Пропуская незнакомый идентификатор молча, cron
|
||||
// оставлял такую сессию жить неограниченно долго. Подробности — в
|
||||
// peer_session-разделе peer_access.go.
|
||||
//
|
||||
// Отказ базы НЕ приводит к разрыву. «Пира нет» и «прочитать не удалось» —
|
||||
// разные ответы, и второй не даёт права рвать ничьи сессии: недоступная SQLite
|
||||
// иначе означала бы отключение всех подключённых пиров сразу. Ошибка чтения
|
||||
// прекращает цикл до единого обращения к `/kick`.
|
||||
//
|
||||
// Обход последовательный. Прежняя реализация раскладывала online-пиров на
|
||||
// чанки по 10 и запускала по горутине на чанк с sync.WaitGroup внутри уже
|
||||
// отсоединённой горутины. Параллельность здесь не нужна: обращений к базе
|
||||
// столько же, а `/kick` всё равно один на весь набор.
|
||||
func enforcePeerAccess(apiPort int64, trafficStatsSecret string) error {
|
||||
online, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(trafficStatsSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Учёт выданных разрешений чистится по фактической картине подключений, и
|
||||
// это единственное место продукта, где она известна целиком. Делается это
|
||||
// до любых решений: уборка ни на что не влияет и ничего не рвёт.
|
||||
sweepDeviceAdmissions(online, time.Now())
|
||||
|
||||
if len(online) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
authIDs := make([]string, 0, len(online))
|
||||
for authID := range online {
|
||||
// Пустой ключ ничему не соответствует: рвать по нему нечего, и в
|
||||
// dedup disconnectAuthIDs он всё равно не попал бы.
|
||||
if authID == "" {
|
||||
continue
|
||||
}
|
||||
authIDs = append(authIDs, authID)
|
||||
}
|
||||
if len(authIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Порядок ключей карты в Go случаен; сортировка делает и обращение к
|
||||
// `/kick`, и журнал воспроизводимыми.
|
||||
sort.Strings(authIDs)
|
||||
|
||||
known := make(map[string]entity.Peer, len(authIDs))
|
||||
for _, chunk := range util.SplitArr(authIDs, 100) {
|
||||
peers, err := dao.ListPeer("auth_id in ?", chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, peer := range peers {
|
||||
// Строка без authId Hysteria не знает. Раньше здесь стояло
|
||||
// `*item.AuthId` без проверки — паника на повреждённой строке
|
||||
// внутри отсоединённой горутины.
|
||||
authID := authIDOf(peer)
|
||||
if authID == "" {
|
||||
continue
|
||||
}
|
||||
known[authID] = peer
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
kick := make([]string, 0, len(authIDs))
|
||||
for _, authID := range authIDs {
|
||||
peer, found := known[authID]
|
||||
if !found {
|
||||
// Сессия, которой в базе больше ничего не соответствует: пир удалён
|
||||
// либо его идентификатор заменён импортом, а разрыв в тот момент не
|
||||
// удался. Восстановить такое состояние переподключением нельзя —
|
||||
// авторизация нового значения не знает, — поэтому единственный
|
||||
// правильный исход тот же, что и у первой попытки.
|
||||
logrus.WithField("authId", authID).
|
||||
Warn("cron: живая сессия без пира в базе; сессия завершается")
|
||||
kick = append(kick, authID)
|
||||
continue
|
||||
}
|
||||
if peerSessionNeedsReconcile(peer, online[authID], now) {
|
||||
kick = append(kick, authID)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой набор до `/kick` не доходит: раньше запрос с пустым массивом в
|
||||
// теле уезжал в Hysteria каждые 30 секунд.
|
||||
return disconnectAuthIDs(kick)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
)
|
||||
|
||||
// Цикл учёта проверяется против НАСТОЯЩЕГО Traffic Stats API.
|
||||
//
|
||||
// accountStatsStub добавляет к trafficStatsStub то, чего у него нет: ответ
|
||||
// `GET /traffic`. Разделять их не нужно — на живом сервере это один и тот же
|
||||
// API на одном порту, и джоба ходит в оба маршрута подряд.
|
||||
|
||||
type accountStatsStub struct {
|
||||
mu sync.Mutex
|
||||
|
||||
traffic map[string]bo.Hysteria2UserTraffic
|
||||
online map[string]int64
|
||||
|
||||
trafficStatus int
|
||||
onlineStatus int
|
||||
kickStatus int
|
||||
|
||||
trafficCalls int
|
||||
onlineCalls int
|
||||
kickCalls int
|
||||
kickedKeys [][]string
|
||||
|
||||
// trafficCleared запоминает, просила ли админка обнулить счётчики.
|
||||
trafficCleared []bool
|
||||
// usageAtOnline — суммарный расход пиров на момент запроса `/online`.
|
||||
// Именно этим доказывается порядок «сначала учёт, потом enforcement»:
|
||||
// после джобы оба шага уже выполнены и проверять там нечего.
|
||||
usageAtOnline []map[string]int64
|
||||
}
|
||||
|
||||
func (s *accountStatsStub) usageSnapshot() map[string]int64 {
|
||||
usage := map[string]int64{}
|
||||
peers, err := dao.ListPeer("1=1")
|
||||
if err != nil {
|
||||
return usage
|
||||
}
|
||||
for _, peer := range peers {
|
||||
if peer.AuthId == nil {
|
||||
continue
|
||||
}
|
||||
var total int64
|
||||
if peer.DownloadBytes != nil {
|
||||
total += *peer.DownloadBytes
|
||||
}
|
||||
if peer.UploadBytes != nil {
|
||||
total += *peer.UploadBytes
|
||||
}
|
||||
usage[*peer.AuthId] = total
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func startAccountStats(t *testing.T, stub *accountStatsStub) *accountStatsStub {
|
||||
t.Helper()
|
||||
|
||||
if stub == nil {
|
||||
stub = &accountStatsStub{}
|
||||
}
|
||||
if stub.traffic == nil {
|
||||
stub.traffic = map[string]bo.Hysteria2UserTraffic{}
|
||||
}
|
||||
if stub.online == nil {
|
||||
stub.online = map[string]int64{}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
stub.mu.Lock()
|
||||
defer stub.mu.Unlock()
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/traffic":
|
||||
stub.trafficCalls++
|
||||
stub.trafficCleared = append(stub.trafficCleared, r.URL.Query().Get("clear") == "1")
|
||||
if stub.trafficStatus != 0 {
|
||||
w.WriteHeader(stub.trafficStatus)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(stub.traffic)
|
||||
case "/online":
|
||||
stub.onlineCalls++
|
||||
stub.usageAtOnline = append(stub.usageAtOnline, stub.usageSnapshot())
|
||||
if stub.onlineStatus != 0 {
|
||||
w.WriteHeader(stub.onlineStatus)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(stub.online)
|
||||
case "/kick":
|
||||
stub.kickCalls++
|
||||
var keys []string
|
||||
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
stub.kickedKeys = append(stub.kickedKeys, keys)
|
||||
if stub.kickStatus != 0 {
|
||||
w.WriteHeader(stub.kickStatus)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
pointHysteriaConfigAt(t, server.URL)
|
||||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||||
}
|
||||
return stub
|
||||
}
|
||||
|
||||
func (s *accountStatsStub) kicked() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
out := []string{}
|
||||
for _, keys := range s.kickedKeys {
|
||||
out = append(out, keys...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// peerUsage помещает пиру расход и настройки доступа.
|
||||
func peerUsage(t *testing.T, id int64, updates map[string]interface{}) {
|
||||
t.Helper()
|
||||
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
|
||||
t.Fatalf("подготовка состояния пира: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Границы принудительного отключения --------------------------------------
|
||||
|
||||
// Главная регрессия QUOTA-01: cron требовал СТРОГОГО превышения квоты, а
|
||||
// авторизация отказывала уже при равенстве. Пир с исчерпанной квотой не
|
||||
// пускался заново, но его живая сессия не разрывалась никогда — он продолжал
|
||||
// пользоваться доступом, пока не переподключался сам.
|
||||
func TestCronKicksPeerAtExactQuota(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{
|
||||
"quota_bytes": int64(1_000),
|
||||
"download_bytes": int64(600),
|
||||
"upload_bytes": int64(400),
|
||||
})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("пир с исчерпанной квотой не отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Нулевая квота — это ноль байтов, а не безлимит. Прежнее условие
|
||||
// `quota_bytes > 0` такую строку не рассматривало вовсе.
|
||||
func TestCronKicksPeerWithZeroQuota(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"quota_bytes": int64(0)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 {
|
||||
t.Fatalf("пир с нулевой квотой не отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronDoesNotKickUnlimitedQuota(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{
|
||||
"quota_bytes": int64(-1),
|
||||
"download_bytes": int64(1 << 40),
|
||||
})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 0 {
|
||||
t.Fatalf("безлимитный пир отключён по квоте: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronKicksPeerWhenExpiryEqualsNow(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
// Срок в недавнем прошлом: «момент наступил» и «момент прошёл» — по
|
||||
// контракту одно и то же, а точное совпадение с now в тесте недостижимо.
|
||||
peerUsage(t, id, map[string]interface{}{"expires_at": time.Now().UnixMilli() - 1})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 {
|
||||
t.Fatalf("пир с истёкшим сроком не отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Блокировка «до» момента, который уже наступил, закончилась: пира отключать
|
||||
// не за что.
|
||||
func TestCronDoesNotKickAfterBanExpired(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"banned_until": time.Now().UnixMilli() - 1})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 0 {
|
||||
t.Fatalf("пир с истёкшей блокировкой отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronKicksBannedPeer(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"banned_until": time.Now().UnixMilli() + 3_600_000})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 {
|
||||
t.Fatalf("заблокированный пир не отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronKicksDisabledPeer(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 {
|
||||
t.Fatalf("отключённый пир не отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Действующий пир не трогается, и запрос без единой цели не отправляется вовсе:
|
||||
// раньше POST с пустым массивом уезжал в Hysteria каждые 30 секунд.
|
||||
func TestCronSendsNoKickWithoutTargets(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
stub.mu.Lock()
|
||||
calls := stub.kickCalls
|
||||
stub.mu.Unlock()
|
||||
if calls != 0 {
|
||||
t.Fatalf("вызов /kick без единой цели: %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Пир, которого Hysteria не считает онлайн, в enforcement не участвует: рвать
|
||||
// у него нечего.
|
||||
func TestCronIgnoresOfflinePeers(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{}})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 0 {
|
||||
t.Fatalf("офлайн-пир попал в /kick: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Сверка живых сессий -----------------------------------------------------
|
||||
|
||||
// Живая сессия, которой в базе больше ничего не соответствует, завершается.
|
||||
//
|
||||
// Прежний обход шёл по НАЙДЕННЫМ пирам, поэтому authID, которого нет в базе,
|
||||
// молча выпадал: `dao.ListPeer("auth_id in ?")` просто не возвращала строку.
|
||||
// Такое состояние возникает после неудавшегося второго шага удаления или
|
||||
// импорта, заменившего `auth_id`, и восстановить его переподключением нельзя —
|
||||
// авторизация нового значения не знает. Сессия жила неограниченно долго.
|
||||
func TestCronKicksSessionWithoutPeerRow(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
online: map[string]int64{"ghost-auth-id": 1},
|
||||
})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "ghost-auth-id" {
|
||||
t.Fatalf("сессия без пира в базе не завершена: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Превышение лимита устройств — свойство живых сессий, а не хранимого
|
||||
// состояния пира, поэтому peerAccessDenied его не видит и видеть не должен.
|
||||
// Без этой проверки неудавшийся разрыв при снижении `maxDevices` оставался бы
|
||||
// навсегда: повторное сохранение формы сравнивает `1 < 1` и разрыва не делает.
|
||||
func TestCronKicksWhenOnlineExceedsMaxDevices(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
online: map[string]int64{"alpha-auth-id": 3},
|
||||
})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"max_devices": int64(1)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("превышение лимита устройств не отключено: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Граница: устройств ровно столько, сколько разрешено, — рвать нечего.
|
||||
func TestCronDoesNotKickAtExactDeviceLimit(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
online: map[string]int64{"alpha-auth-id": 3},
|
||||
})
|
||||
// seedPeer создаёт пира с maxDevices = 3.
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 0 {
|
||||
t.Fatalf("пир на границе лимита отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Повреждённая граница — не «безлимит». На пути авторизации такая строка ведёт
|
||||
// к отказу, и живая сессия обязана следовать тому же правилу.
|
||||
func TestCronKicksPeerWithUnusableMaxDevices(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
online: map[string]int64{"alpha-auth-id": 1},
|
||||
})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"max_devices": int64(0)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 {
|
||||
t.Fatalf("пир с непригодным лимитом устройств не отключён: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ базы НЕ является основанием рвать сессии.
|
||||
//
|
||||
// «Пира нет» и «прочитать не удалось» — разные ответы, и решение «сессии
|
||||
// неизвестны, значит лишние» на втором из них отключило бы всех подключённых
|
||||
// пиров сразу при недоступной SQLite. Проверка существует именно потому, что
|
||||
// правило «неизвестный authID -> kick» делает это различие решающим.
|
||||
func TestCronSendsNoKickWhenPeerLookupFails(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
online: map[string]int64{"alpha-auth-id": 1},
|
||||
})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
t.Fatalf("порт Traffic Stats API: %v", err)
|
||||
}
|
||||
// Порт и секрет читаются из конфига Hysteria, поэтому база после этого уже
|
||||
// не нужна ни для чего, кроме самой выборки пиров.
|
||||
if err := dao.CloseSqliteDB(); err != nil {
|
||||
t.Fatalf("не удалось закрыть базу: %v", err)
|
||||
}
|
||||
|
||||
if err := enforcePeerAccess(apiPort, testTrafficStatsSecret); err == nil {
|
||||
t.Fatal("отказ базы не сообщён вызывающему")
|
||||
}
|
||||
if got := stub.kicked(); len(got) != 0 {
|
||||
t.Fatalf("отказ базы привёл к разрыву сессий: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Учёт выданных разрешений чистится по фактической картине подключений.
|
||||
func TestCronSweepsAdmissionsOfOfflinePeers(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startAccountStats(t, &accountStatsStub{online: map[string]int64{}})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
// Разрешение выдано давно и уже протухло, подключения так и не случилось.
|
||||
if !reserveDeviceSlot("alpha-auth-id", 0, 3, time.Now().Add(-2*pendingAdmissionTTL)) {
|
||||
t.Fatal("подготовка учёта: разрешение отклонено")
|
||||
}
|
||||
if admissionEntries() != 1 {
|
||||
t.Fatal("подготовка учёта: запись не создана")
|
||||
}
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := admissionEntries(); got != 0 {
|
||||
t.Fatalf("учёт не убран: записей %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Сходимость после неудавшегося разрыва -----------------------------------
|
||||
|
||||
// Импорт заменил `auth_id`, а разрыв старой сессии не удался. Повторить его
|
||||
// операцией импорта невозможно: в базе уже новое значение, и повтор того же
|
||||
// файла разорвал бы именно его. Сходимость обеспечивает cron.
|
||||
func TestCronReconcilesSessionAfterFailedImportKick(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
kickStatus: http.StatusInternalServerError,
|
||||
online: map[string]int64{"old-auth-id": 1},
|
||||
})
|
||||
seedPeer(t, "keeper", "old-auth-id")
|
||||
|
||||
requireDisconnectError(t, UpsertPeerExport([]bo.PeerExport{importItem("keeper", "new-auth-id")}))
|
||||
|
||||
after := snapshotPeers(t)["keeper"]
|
||||
if after.AuthId == nil || *after.AuthId != "new-auth-id" {
|
||||
t.Fatalf("импорт не применён: authId=%v", after.AuthId)
|
||||
}
|
||||
|
||||
stub.mu.Lock()
|
||||
stub.kickStatus = 0
|
||||
stub.kickedKeys = nil
|
||||
stub.mu.Unlock()
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "old-auth-id" {
|
||||
t.Fatalf("старая сессия не завершена следующим циклом учёта: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Лимит устройств снижен, разрыв не удался, оператор повторяет сохранение
|
||||
// формы — и получает успех без разрыва, потому что новое значение уже в базе.
|
||||
// Единственный механизм схождения здесь — cron.
|
||||
func TestCronReconcilesSessionAfterFailedMaxDevicesReduction(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
kickStatus: http.StatusInternalServerError,
|
||||
online: map[string]int64{"alpha-auth-id": 3},
|
||||
})
|
||||
// seedPeer создаёт пира с maxDevices = 3.
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{MaxDevices: int64Ptr(1)}))
|
||||
|
||||
// Повтор формы: значение то же самое, разрыва не будет — и это правильно,
|
||||
// иначе каждое сохранение любой правки рвало бы сессии.
|
||||
if err := UpdatePeer(id, dto.PeerUpdateDto{MaxDevices: int64Ptr(1)}); err != nil {
|
||||
t.Fatalf("повторное сохранение формы отказало: %v", err)
|
||||
}
|
||||
|
||||
stub.mu.Lock()
|
||||
stub.kickStatus = 0
|
||||
stub.kickedKeys = nil
|
||||
stub.mu.Unlock()
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||||
t.Fatalf("превышение лимита не устранено следующим циклом учёта: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Порядок и устройство цикла ----------------------------------------------
|
||||
|
||||
// Enforcement принимает решение по счётчикам, поэтому счётчики обязаны быть
|
||||
// обновлены ДО него. Раньше обе половины запускались параллельными горутинами,
|
||||
// и превышение квоты замечалось в лучшем случае со следующего тика.
|
||||
func TestCronCollectsTrafficBeforeEnforcing(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
traffic: map[string]bo.Hysteria2UserTraffic{
|
||||
"alpha-auth-id": {Rx: 600, Tx: 400},
|
||||
},
|
||||
online: map[string]int64{"alpha-auth-id": 1},
|
||||
})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"quota_bytes": int64(1_000)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
stub.mu.Lock()
|
||||
usage := stub.usageAtOnline
|
||||
stub.mu.Unlock()
|
||||
|
||||
if len(usage) == 0 {
|
||||
t.Fatal("enforcement не выполнялся")
|
||||
}
|
||||
if got := usage[0]["alpha-auth-id"]; got != 1_000 {
|
||||
t.Fatalf("enforcement увидел расход %d — дельта ещё не была записана", got)
|
||||
}
|
||||
// И следствие: превышение замечено в ТОМ ЖЕ тике, а не в следующем.
|
||||
if kicked := stub.kicked(); len(kicked) != 1 {
|
||||
t.Fatalf("исчерпавший квоту пир не отключён в том же цикле: %v", kicked)
|
||||
}
|
||||
}
|
||||
|
||||
// Чтение трафика деструктивно по контракту Traffic Stats API: без clear=1
|
||||
// счётчики Hysteria не обнуляются, и следующий сбор посчитал бы тот же трафик
|
||||
// повторно.
|
||||
func TestCronClearsTrafficCounters(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
traffic: map[string]bo.Hysteria2UserTraffic{"alpha-auth-id": {Rx: 1, Tx: 1}},
|
||||
})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
stub.mu.Lock()
|
||||
cleared := stub.trafficCleared
|
||||
stub.mu.Unlock()
|
||||
|
||||
if len(cleared) != 1 || !cleared[0] {
|
||||
t.Fatalf("сбор трафика выполнен без clear=1: %v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// Дельта трафика, которую не удалось приписать пиру, считается потерей и
|
||||
// попадает в исход джобы.
|
||||
//
|
||||
// Чтение `?clear=1` деструктивно по контракту Traffic Stats API: счётчики
|
||||
// Hysteria обнуляются сразу после отправки ответа, поэтому каждая дельта
|
||||
// существует ровно в одном экземпляре. Раньше такой случай делал `continue` и
|
||||
// не оставлял следа вовсе.
|
||||
func TestSaveAccountTrafficCountsLostDeltas(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startAccountStats(t, &accountStatsStub{
|
||||
traffic: map[string]bo.Hysteria2UserTraffic{
|
||||
"known-auth-id": {Rx: 10, Tx: 20},
|
||||
"unknown-auth-id": {Rx: 30, Tx: 40},
|
||||
},
|
||||
})
|
||||
seedPeer(t, "alpha1", "known-auth-id")
|
||||
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
t.Fatalf("порт Traffic Stats API: %v", err)
|
||||
}
|
||||
|
||||
err = saveAccountTraffic(apiPort, testTrafficStatsSecret)
|
||||
if err == nil {
|
||||
t.Fatal("потеря дельты не сообщена вызывающему")
|
||||
}
|
||||
var loss *trafficLossError
|
||||
if !errors.As(err, &loss) {
|
||||
t.Fatalf("потеря сообщена не как величина: %v", err)
|
||||
}
|
||||
if loss.lost != 1 {
|
||||
t.Fatalf("учтено %d потерь, ожидалась 1", loss.lost)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "1") {
|
||||
t.Errorf("сообщение не называет количество: %q", err.Error())
|
||||
}
|
||||
|
||||
// Известный пир при этом обязан получить свою дельту: потеря одной записи
|
||||
// не отменяет остальных.
|
||||
peer := snapshotPeers(t)["alpha1"]
|
||||
if *peer.DownloadBytes != 10 || *peer.UploadBytes != 20 {
|
||||
t.Fatalf("дельта известного пира не записана: %d/%d", *peer.DownloadBytes, *peer.UploadBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Мнение systemd на цикл учёта не влияет.
|
||||
//
|
||||
// Прежний гейт `if !Hysteria2IsRunning() { return }` стоял на решении о
|
||||
// применении операции, а util.Exec не отличает «служба неактивна» от
|
||||
// «спросить не удалось»: сломанный systemctl при живой Hysteria молча отключал
|
||||
// и учёт трафика, и принудительное отключение — без единой строки в журнале.
|
||||
func TestCronRunsWhenSystemdSaysStopped(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
|
||||
withHysteriaServiceState(t, HysteriaServiceInactive)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 {
|
||||
t.Fatalf("мнение systemd отключило принудительное отключение: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Отсутствующее значение секрета Traffic Stats API — отказ джобы, а не паника.
|
||||
//
|
||||
// Раньше здесь стояло `*trafficSecretConfig.Value` без проверки, причём внутри
|
||||
// отсоединённой горутины: разыменование nil роняло бы весь процесс вместе с
|
||||
// обработчиком machine-auth, а не одну джобу.
|
||||
func TestCronSurvivesMissingTrafficSecret(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startAccountStats(t, nil)
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
// Пустое значение ключа неотличимо от его отсутствия: и то и другое
|
||||
// означает «секрета нет». Прежний путь читал `*config.Value` без проверки
|
||||
// и на строке без значения падал с nil-разыменованием.
|
||||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, ""); err != nil {
|
||||
t.Fatalf("не удалось стереть секрет: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("отсутствующий секрет уронил джобу учёта: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
CronHandleAccount()
|
||||
}
|
||||
|
||||
// Строка пира без идентификатора не роняет сброс трафика: раньше `*item.Id`
|
||||
// разыменовывался без проверки.
|
||||
func TestCronResetTrafficSurvivesRowWithoutID(t *testing.T) {
|
||||
newTestDB(t)
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"download_bytes": int64(100), "upload_bytes": int64(200)})
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("сброс трафика упал: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
CronResetTraffic()
|
||||
|
||||
peer := snapshotPeers(t)["alpha1"]
|
||||
if *peer.DownloadBytes != 0 || *peer.UploadBytes != 0 {
|
||||
t.Fatalf("счётчики не сброшены: %d/%d", *peer.DownloadBytes, *peer.UploadBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ `/traffic` не отменяет enforcement: политика применяется по уже
|
||||
// известным счётчикам, а не пропускается вместе со сбором.
|
||||
func TestCronEnforcesEvenWhenTrafficCollectionFails(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
trafficStatus: http.StatusInternalServerError,
|
||||
online: map[string]int64{"alpha-auth-id": 1},
|
||||
})
|
||||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
peerUsage(t, id, map[string]interface{}{"disabled": int64(1)})
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
if got := stub.kicked(); len(got) != 1 {
|
||||
t.Fatalf("отказ сбора трафика отменил принудительное отключение: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Второй тик поверх идущего цикла не запускает второй цикл. Проверяется
|
||||
// наблюдаемым следствием: при удерживаемом мьютексе джоба обязана вернуться,
|
||||
// не сходив в Hysteria ни разу.
|
||||
func TestCronHandleAccountSkipsOverlappingTick(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, nil)
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
accountJobMutex.Lock()
|
||||
CronHandleAccount()
|
||||
accountJobMutex.Unlock()
|
||||
|
||||
stub.mu.Lock()
|
||||
calls := stub.trafficCalls + stub.onlineCalls
|
||||
stub.mu.Unlock()
|
||||
if calls != 0 {
|
||||
t.Fatalf("параллельный тик запустил второй цикл учёта: обращений %d", calls)
|
||||
}
|
||||
|
||||
// А после освобождения обычный тик проходит.
|
||||
CronHandleAccount()
|
||||
stub.mu.Lock()
|
||||
calls = stub.trafficCalls + stub.onlineCalls
|
||||
stub.mu.Unlock()
|
||||
if calls == 0 {
|
||||
t.Fatal("цикл учёта не выполнился после освобождения мьютекса")
|
||||
}
|
||||
}
|
||||
|
||||
// Джоба СИНХРОННА: планировщик обязан видеть её работу, иначе StopCron
|
||||
// возвращается, releaseResource закрывает SQLite, а недобитые горутины
|
||||
// продолжают писать в закрытое соединение.
|
||||
//
|
||||
// Доказывается тем, что к моменту возврата CronHandleAccount вся работа уже
|
||||
// сделана — при отсоединённых горутинах обращения к Hysteria к этому моменту
|
||||
// ещё не случились бы.
|
||||
func TestCronHandleAccountIsSynchronous(t *testing.T) {
|
||||
newTestDB(t)
|
||||
stub := startAccountStats(t, &accountStatsStub{
|
||||
traffic: map[string]bo.Hysteria2UserTraffic{"alpha-auth-id": {Rx: 10, Tx: 20}},
|
||||
online: map[string]int64{"alpha-auth-id": 1},
|
||||
})
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
CronHandleAccount()
|
||||
|
||||
stub.mu.Lock()
|
||||
trafficCalls := stub.trafficCalls
|
||||
onlineCalls := stub.onlineCalls
|
||||
stub.mu.Unlock()
|
||||
|
||||
if trafficCalls != 1 || onlineCalls != 1 {
|
||||
t.Fatalf("работа не завершена к возврату джобы: /traffic %d, /online %d", trafficCalls, onlineCalls)
|
||||
}
|
||||
|
||||
// И записанная дельта уже видна: значит цикл дошёл до конца, а не был
|
||||
// передан горутине.
|
||||
peer := snapshotPeers(t)["alpha1"]
|
||||
if *peer.DownloadBytes != 10 || *peer.UploadBytes != 20 {
|
||||
t.Fatalf("дельта не записана к возврату джобы: %d/%d", *peer.DownloadBytes, *peer.UploadBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// StopCron дожидается запущенной джобы учёта. Раньше внешняя горутина
|
||||
// заканчивалась мгновенно, и планировщику было нечего ждать.
|
||||
func TestStopCronWaitsForAccountJob(t *testing.T) {
|
||||
newTestDB(t)
|
||||
startAccountStats(t, nil)
|
||||
seedPeer(t, "alpha1", "alpha-auth-id")
|
||||
|
||||
// Джоба удерживается занятым мьютексом: пока он не освобождён, ни один
|
||||
// цикл учёта не идёт, и StopCron обязан вернуться без ожидания.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
accountJobMutex.Lock()
|
||||
defer accountJobMutex.Unlock()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}()
|
||||
|
||||
if err := InitCron(); err != nil {
|
||||
t.Fatalf("InitCron: %v", err)
|
||||
}
|
||||
StopCron()
|
||||
<-done
|
||||
|
||||
if count := CronEntryCount(); count != 0 {
|
||||
t.Fatalf("после остановки осталось %d записей", count)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ func DashboardSummary() (vo.DashboardSummaryVo, error) {
|
||||
summary := vo.DashboardSummaryVo{CollectedAt: nowMs}
|
||||
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"}
|
||||
summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"}
|
||||
// До первого снимка сборщика о службе не известно ничего. Нулевое значение
|
||||
// `Running = false` прочиталось бы как «остановлена» — то самое смешение
|
||||
// «неизвестно» и «выключено», от которого избавляет HysteriaServiceState.
|
||||
summary.Hysteria.ServiceState = string(HysteriaServiceUnknown)
|
||||
|
||||
snapshot := DashboardSnapshot()
|
||||
if snapshot.CollectedAt > 0 {
|
||||
@@ -119,9 +123,21 @@ func DashboardSecurity() ([]vo.SecurityRiskVo, error) {
|
||||
return summary.SecurityRisks, nil
|
||||
}
|
||||
|
||||
// DashboardSecurityRisks называет ровно то, что известно.
|
||||
//
|
||||
// «Служба остановлена» и «состояние службы неизвестно» — разные сообщения с
|
||||
// разными действиями оператора. Пока состояние было булевым, второе выдавалось
|
||||
// за первое: сломанный systemctl при живой Hysteria показывал критическую
|
||||
// плашку «служба остановлена», и оператор шёл перезапускать работающий
|
||||
// туннель.
|
||||
func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo {
|
||||
risks := make([]vo.SecurityRiskVo, 0)
|
||||
if !summary.Hysteria.Running {
|
||||
switch summary.Hysteria.ServiceState {
|
||||
case string(HysteriaServiceActive):
|
||||
// Обслуживание идёт — сообщать нечего.
|
||||
case string(HysteriaServiceUnknown):
|
||||
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStateUnknown", Severity: "warning", Dismissible: false})
|
||||
default:
|
||||
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStopped", Severity: "critical", Dismissible: false})
|
||||
}
|
||||
if !summary.Hysteria.ApiReachable {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
|
||||
// InitHysteria2 проверяет единственное, что панели действительно нужно от
|
||||
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
|
||||
// у которого спрашивает состояние Hysteria2IsRunning.
|
||||
// у которого спрашивает состояние HysteriaServiceState.
|
||||
//
|
||||
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
|
||||
// в которой панель сама запускала и останавливала Hysteria: clean install
|
||||
@@ -26,11 +27,99 @@ func InitHysteria2() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Hysteria2IsRunning() bool {
|
||||
_, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
||||
return err == nil
|
||||
// Состояние службы имеет ТРИ значения, а не два.
|
||||
//
|
||||
// Что было. Ответ сводился к `bool`:
|
||||
//
|
||||
// func Hysteria2IsRunning() bool {
|
||||
// _, err := util.Exec("systemctl is-active --quiet hysteria-server")
|
||||
// return err == nil
|
||||
// }
|
||||
//
|
||||
// `false` при этом означал сразу две несовместимые вещи — «служба неактивна» и
|
||||
// «спросить не получилось», — потому что util.Exec выбрасывает вывод команды,
|
||||
// как только код возврата не нулевой, а `is-active` отвечает именно кодом 3
|
||||
// вместе со словом состояния в stdout. Прочитать это слово было нечем.
|
||||
//
|
||||
// Цена такой неопределённости оказалась не в одной неверной плашке. Пока
|
||||
// «неизвестно» выглядело как «остановлена», на этом значении стояли решения:
|
||||
// цикл учёта и проверка лимита устройств пропускались при сломанном systemctl
|
||||
// и живой Hysteria. Оба гейта убраны (см. cron.go и Hysteria2Auth), и это
|
||||
// правильно, но сама неопределённость никуда не делась — она осталась в
|
||||
// диагностике, где как раз и вредна:
|
||||
//
|
||||
// Hysteria stopped
|
||||
// Traffic Stats API reachable
|
||||
// 0 online users
|
||||
//
|
||||
// Три утверждения об одной системе, из которых первое и второе не могут быть
|
||||
// верны одновременно. Оператор, увидев такое в аварии, ищет не там.
|
||||
//
|
||||
// Теперь состояние читается словом, а не кодом возврата, и неизвестность
|
||||
// называется неизвестностью.
|
||||
type HysteriaServiceState string
|
||||
|
||||
const (
|
||||
// HysteriaServiceActive — systemd сообщил "active".
|
||||
HysteriaServiceActive HysteriaServiceState = "active"
|
||||
// HysteriaServiceInactive — systemd назвал любое другое известное
|
||||
// состояние: inactive, failed, activating, deactivating.
|
||||
//
|
||||
// Различать их дальше продукту незачем: панель отвечает на вопрос «идёт ли
|
||||
// сейчас обслуживание», а не пересказывает конечный автомат systemd.
|
||||
HysteriaServiceInactive HysteriaServiceState = "inactive"
|
||||
// HysteriaServiceUnknown — спросить не удалось либо ответ не разобран.
|
||||
//
|
||||
// Это НЕ «служба остановлена». Единственное честное следствие — что о
|
||||
// состоянии службы сейчас ничего не известно.
|
||||
HysteriaServiceUnknown HysteriaServiceState = "unknown"
|
||||
)
|
||||
|
||||
// Hysteria2ServiceState — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
|
||||
//
|
||||
// Значение годится для отображения и ни для чего больше. Решения о доступе
|
||||
// принимаются по фактическому ответу Traffic Stats API — см. hysteria2Online и
|
||||
// disconnectAuthIDs, — и это правило стоило продукту трёх дыр: гейт
|
||||
// `if !Hysteria2IsRunning() { return }` в cron молча отключал и сбор трафика, и
|
||||
// принудительное отключение при сломанном systemctl.
|
||||
func Hysteria2ServiceState() HysteriaServiceState {
|
||||
return hysteriaServiceState()
|
||||
}
|
||||
|
||||
func ReleaseHysteria2() error {
|
||||
return nil
|
||||
// hysteriaServiceState — та же проверка в виде значения.
|
||||
//
|
||||
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
|
||||
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
|
||||
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
|
||||
// реализация.
|
||||
var hysteriaServiceState = func() HysteriaServiceState {
|
||||
// `--quiet` здесь недопустим: он подавляет ровно тот вывод, ради которого
|
||||
// команда вызывается.
|
||||
output, _, err := util.ExecProbe("systemctl is-active hysteria-server")
|
||||
if err != nil {
|
||||
return HysteriaServiceUnknown
|
||||
}
|
||||
return parseSystemdActiveState(output)
|
||||
}
|
||||
|
||||
// parseSystemdActiveState разбирает ответ `systemctl is-active`.
|
||||
//
|
||||
// Слово состояния приезжает первой строкой. Неизвестное значение считается
|
||||
// неизвестным состоянием, а не «неактивна»: список состояний systemd
|
||||
// пополнялся и раньше, и молчаливое приведение нового слова к «остановлена»
|
||||
// вернуло бы ровно ту ложь, от которой избавляет этот тип.
|
||||
func parseSystemdActiveState(output string) HysteriaServiceState {
|
||||
state := strings.TrimSpace(output)
|
||||
if idx := strings.IndexAny(state, "\r\n"); idx >= 0 {
|
||||
state = strings.TrimSpace(state[:idx])
|
||||
}
|
||||
|
||||
switch state {
|
||||
case "active":
|
||||
return HysteriaServiceActive
|
||||
case "inactive", "failed", "activating", "deactivating", "reloading", "maintenance":
|
||||
return HysteriaServiceInactive
|
||||
default:
|
||||
return HysteriaServiceUnknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/proxy"
|
||||
"hy2xs-admin/util"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -48,7 +49,10 @@ func resolvePublicEndpoint() (string, int, error) {
|
||||
}
|
||||
port, err := strconv.Atoi(portRaw)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid TCP port")
|
||||
// Транспорт в формулировке не называется: публичный endpoint Hysteria —
|
||||
// UDP/QUIC, и «TCP port» здесь закладывал в сообщение об ошибке
|
||||
// заведомо ложную семантику.
|
||||
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid port")
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
@@ -59,74 +63,240 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
|
||||
if digestErr != nil {
|
||||
return 0, "", digestErr
|
||||
}
|
||||
peer, err := dao.GetPeer(`secret_digest = ?
|
||||
and disabled = 0
|
||||
and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes)
|
||||
and (expires_at = 0 or ? < expires_at)
|
||||
and ? > banned_until`, secretDigest, now, now)
|
||||
// Поиск идёт ТОЛЬКО по учётным данным. Политика доступа больше не живёт
|
||||
// внутри выборки: её объявляет peerAccessDenied, и ровно её же применяет
|
||||
// принудительное отключение в cron. Пока правило было записано двумя
|
||||
// разными SQL-условиями, авторизация и enforcement расходились на границах
|
||||
// quota, expiry и ban — см. комментарий в peer_access.go.
|
||||
peer, err := dao.GetPeer("secret_digest = ?", secretDigest)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
// Ограничение количества устройств
|
||||
onlineUsers, err := Hysteria2Online()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn("hysteria2 online users unavailable; skip device-limit check")
|
||||
return *peer.Id, *peer.AuthId, nil
|
||||
if peerAccessDenied(peer, now) {
|
||||
return 0, "", errors.New("peer access denied")
|
||||
}
|
||||
device, exist := onlineUsers[*peer.AuthId]
|
||||
if exist && *peer.MaxDevices <= device {
|
||||
|
||||
// Строка без идентичности — повреждённые данные, а не пир.
|
||||
//
|
||||
// Проверка стоит здесь по той же причине, что и проверка maxDevices ниже:
|
||||
// это путь КАЖДОГО подключения пира, и разыменование nil на нём означает
|
||||
// панику в обработчике machine-auth, а не отказ одному клиенту.
|
||||
if peer.Id == nil || peer.AuthId == nil || *peer.AuthId == "" {
|
||||
logrus.Error("peer row has no usable identity; rejecting auth")
|
||||
return 0, "", errors.New("peer identity unavailable")
|
||||
}
|
||||
|
||||
// Ограничение количества устройств — fail-closed.
|
||||
//
|
||||
// Раньше отказ Traffic Stats API обрабатывался так:
|
||||
//
|
||||
// onlineUsers, err := Hysteria2Online()
|
||||
// if err != nil {
|
||||
// logrus.WithError(err).Warn(...)
|
||||
// return *peer.Id, *peer.AuthId, nil
|
||||
// }
|
||||
//
|
||||
// То есть недоступность внутреннего 127.0.0.1 превращала объявленный в
|
||||
// панели «Лимит устройств: 3» в безлимит, и узнать об этом оператор мог
|
||||
// только по строке WARN в журнале, которую никто не читает. Ограничение,
|
||||
// которое отключается само при первой же внутренней неполадке, не является
|
||||
// ограничением.
|
||||
//
|
||||
// Вторая половина той же дыры была тише: общий Hysteria2Online отдавал
|
||||
// пустую карту БЕЗ ошибки, когда systemd отвечал «служба неактивна», —
|
||||
// а этот ответ не отличается от «спросить systemctl не удалось». Поэтому
|
||||
// здесь берётся строгий путь: только фактический ответ Traffic Stats API.
|
||||
//
|
||||
// Направление отказа выбрано осознанно. Запрос авторизации приходит ОТ
|
||||
// Hysteria, то есть в момент этой проверки Hysteria заведомо жива, а её
|
||||
// Traffic Stats API слушает loopback внутри того же процесса. Его
|
||||
// недоступность здесь — не штатное состояние, а аномалия, и пускать
|
||||
// подключения без единственной проверки, которая ещё не выполнена, значит
|
||||
// молча снять лимит со всех пиров сразу.
|
||||
|
||||
// Чтение `/online` и резервация места — ОДНА последовательность, и она
|
||||
// выполняется под замком этого пира.
|
||||
//
|
||||
// Без замка снимки приходили в резервацию в произвольном порядке, и
|
||||
// устаревший откатывал учёт назад: разрешение, уже признанное проявившимся,
|
||||
// возвращалось в «свободное место». Подробный разбор — в начале
|
||||
// peer_admission.go.
|
||||
//
|
||||
// Замок берётся именно здесь, а не раньше: до этой точки известен только
|
||||
// секрет, а сериализовать нужно подключения ОДНОГО пира, то есть замок
|
||||
// невозможно взять, пока не прочитан его authId. Всё, что выше, — работа с
|
||||
// базой и политикой доступа, и разным пирам она не мешает.
|
||||
unlockAdmission := lockPeerAdmission(*peer.AuthId)
|
||||
defer unlockAdmission()
|
||||
|
||||
onlineUsers, err := hysteria2Online()
|
||||
if err != nil {
|
||||
logrus.WithError(err).
|
||||
WithField("peerId", *peer.Id).
|
||||
Error("hysteria2 traffic stats api unavailable; device limit cannot be enforced, rejecting auth")
|
||||
return 0, "", errors.New("device limit unavailable")
|
||||
}
|
||||
|
||||
// maxDevices без значения — это не «безлимит», а неизвестная граница.
|
||||
// Схема даёт колонке DEFAULT, форма требует min=1, импорт приводит <=0 к 3,
|
||||
// поэтому nil здесь означать может только повреждённую строку — и на пути
|
||||
// принятия решения о доступе она обязана вести к отказу, а не к пропуску.
|
||||
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
|
||||
logrus.WithField("peerId", *peer.Id).
|
||||
Error("peer has no usable maxDevices; rejecting auth")
|
||||
return 0, "", errors.New("device limit unavailable")
|
||||
}
|
||||
|
||||
// Место занимается ПОСЛЕ всех остальных проверок и с учётом уже выданных,
|
||||
// но ещё не проявившихся разрешений — см. peer_admission.go. Сравнение
|
||||
// одного лишь ответа `/online` пропускало параллельные подключения: между
|
||||
// чтением и ответом «allow» ничего не удерживало место, и два одновременных
|
||||
// запроса при `online=2, max=3` получали разрешение оба.
|
||||
//
|
||||
// Порядок существенен: если бы резервация делалась раньше проверки
|
||||
// квоты или срока, отказ по ним съедал бы слот на всё время TTL.
|
||||
if !reserveDeviceSlot(*peer.AuthId, onlineUsers[*peer.AuthId], *peer.MaxDevices, time.Now()) {
|
||||
return 0, "", errors.New("device limited")
|
||||
}
|
||||
|
||||
return *peer.Id, *peer.AuthId, nil
|
||||
}
|
||||
|
||||
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
|
||||
//
|
||||
// Отличается от hysteria2Online ровно ничем, и это результат исправления, а не
|
||||
// упущение. Раньше здесь стоял ярлык
|
||||
//
|
||||
// if !hysteria2IsRunning() {
|
||||
// return map[string]int64{}, nil
|
||||
// }
|
||||
//
|
||||
// то есть «пусто, ошибки нет» по мнению systemd. У него было два следствия, и
|
||||
// оба вредные.
|
||||
//
|
||||
// Первое — на пути доступа: ответ systemctl не отличает «служба неактивна» от
|
||||
// «спросить не удалось», а пустая картина при проверке лимита устройств
|
||||
// означает «пускать всех». Эта половина закрыта раньше — авторизация ходит
|
||||
// строгим путём.
|
||||
//
|
||||
// Второе осталось и живёт в диагностике. Пустая карта БЕЗ ошибки неотличима от
|
||||
// «никто не подключён», поэтому сборщик метрик выставлял `ApiReachable = true`
|
||||
// и `OnlineDevices = 0`, ни разу не обратившись к Traffic Stats API, а список
|
||||
// пиров показывал всех офлайн. Дашборд утверждал одновременно «служба
|
||||
// остановлена» и «API доступен, онлайн 0» — два несовместимых факта об одной
|
||||
// системе, полученные из одного и того же ответа systemctl.
|
||||
//
|
||||
// Поэтому ярлыка нет: «кто сейчас на связи» спрашивается у того, кто это
|
||||
// знает. Недоступность остаётся ОШИБКОЙ, а решать, как её показать оператору,
|
||||
// обязан вызывающий — см. CollectMetricsSnapshot и PagePeer, где она
|
||||
// превращается в явное «состояние неизвестно», а не в «все офлайн».
|
||||
//
|
||||
// Функция сохранена отдельно от hysteria2Online как имя для внешнего слоя:
|
||||
// внутри пакета строгий путь остаётся строчным.
|
||||
func Hysteria2Online() (map[string]int64, error) {
|
||||
if !Hysteria2IsRunning() {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
return hysteria2Online()
|
||||
}
|
||||
|
||||
// hysteria2Online — фактический ответ Traffic Stats API, без ярлыков.
|
||||
//
|
||||
// Недоступность здесь остаётся ошибкой: вызывающий обязан решить, что она для
|
||||
// него значит, и не может получить пустую карту вместо отказа.
|
||||
func hysteria2Online() (map[string]int64, error) {
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
return nil, errors.New("get hysteria2 apiPort err")
|
||||
}
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
secret, err := hysteria2TrafficSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*trafficSecretConfig.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return onlineUsers, nil
|
||||
return proxy.NewHysteria2Api(apiPort).OnlineUsers(secret)
|
||||
}
|
||||
|
||||
func Hysteria2Kick(ids []int64, kickUtilTime int64) error {
|
||||
if !Hysteria2IsRunning() {
|
||||
return errors.New("hysteria2 is not running")
|
||||
// hysteria2TrafficSecret отдаёт секрет Traffic Stats API.
|
||||
//
|
||||
// Отсутствующее значение ключа — отказ, а не пустая строка. Раньше по этому
|
||||
// пути стояло `*config.Value` без проверки: строка в таблице `config` без
|
||||
// значения роняла бы админку паникой на разыменовании nil прямо в обработчике
|
||||
// machine-auth, то есть на пути каждого подключения пира.
|
||||
func hysteria2TrafficSecret() (string, error) {
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := dao.UpdatePeer(ids, map[string]interface{}{"banned_until": kickUtilTime}); err != nil {
|
||||
return err
|
||||
if trafficSecretConfig.Value == nil || *trafficSecretConfig.Value == "" {
|
||||
return "", errors.New("hysteria2 traffic stats secret is not configured")
|
||||
}
|
||||
return *trafficSecretConfig.Value, nil
|
||||
}
|
||||
|
||||
// kickChunkSize ограничивает размер одного обращения к `/kick`.
|
||||
//
|
||||
// Импорт применяет до MaxPeerImportItems записей за операцию, и без разбиения
|
||||
// в Hysteria уехал бы один POST с многотысячным массивом в теле. Значение
|
||||
// выбрано с запасом относительно любого реального размера панели: смысл здесь
|
||||
// не в оптимизации, а в отсутствии запроса, размер которого задаёт содержимое
|
||||
// пользовательского файла.
|
||||
const kickChunkSize = 100
|
||||
|
||||
// disconnectAuthIDs — ЕДИНСТВЕННЫЙ путь к Traffic Stats `/kick` в продукте.
|
||||
//
|
||||
// Контракт предельно узкий и намеренно ничего не знает про пиров:
|
||||
//
|
||||
// auth IDs -> дедупликация -> порт API -> секрет -> POST /kick
|
||||
//
|
||||
// Никакой базы, никакого `disabled`, никакого `banned_until`. Разрыв сессии и
|
||||
// запись состояния разделены сознательно: прежний Hysteria2Kick делал и то и
|
||||
// другое — вместе с обращением к `/kick` он проставлял `banned_until`, — и
|
||||
// из-за этого им нельзя было воспользоваться для отключения пира: операция
|
||||
// записала бы заодно временную блокировку, а это другой механизм с другим
|
||||
// сроком жизни и другим способом снятия.
|
||||
//
|
||||
// Вход — именно auth IDs, а не идентификаторы пиров, и это не деталь. Операции
|
||||
// удаления и импорта меняют или убирают auth ID: после commit действующего
|
||||
// значения в базе уже нет, и рвать надо по тому, которое Hysteria знала ДО
|
||||
// операции. Функция, которая сама читала бы auth ID из базы, для этих двух
|
||||
// путей опоздала бы всегда.
|
||||
//
|
||||
// Состояние службы по systemd НЕ проверяется. Ответ systemd не отличает
|
||||
// «служба неактивна» от «спросить не удалось» (см. Hysteria2IsRunning),
|
||||
// поэтому сбой самого systemctl отказывал бы операции при живой Hysteria.
|
||||
// Обращение к `/kick` отвечает на нужный вопрос напрямую и без посредника.
|
||||
func disconnectAuthIDs(authIDs []string) error {
|
||||
keys := make([]string, 0, len(authIDs))
|
||||
seen := make(map[string]struct{}, len(authIDs))
|
||||
for _, authID := range authIDs {
|
||||
if authID == "" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[authID]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[authID] = struct{}{}
|
||||
keys = append(keys, authID)
|
||||
}
|
||||
|
||||
peers, err := dao.ListPeer("id in ?", ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var keys []string
|
||||
for _, item := range peers {
|
||||
keys = append(keys, *item.AuthId)
|
||||
// Ни одной цели — значит рвать нечего, и это не отказ. Раньше по
|
||||
// аналогичному пути в cron уезжал POST с пустым массивом каждые 30 секунд.
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
apiPort, err := GetHysteria2ApiPort()
|
||||
if err != nil {
|
||||
return errors.New("get hysteria2 apiPort err")
|
||||
}
|
||||
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||
secret, err := hysteria2TrafficSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *trafficSecretConfig.Value); err != nil {
|
||||
return err
|
||||
|
||||
api := proxy.NewHysteria2Api(apiPort)
|
||||
for _, chunk := range util.SplitArr(keys, kickChunkSize) {
|
||||
if err := api.KickUsers(chunk, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -176,7 +346,7 @@ func Hysteria2Url(accountId int64) (string, error) {
|
||||
// несколькими пирами это ровно то различие, которое пользователю и нужно
|
||||
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на
|
||||
// публичный хост нужен для пира без имени — база это допускает (name имеет
|
||||
// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
|
||||
// DEFAULT ”), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
|
||||
func shareRemark(peerName *string, hostname string) string {
|
||||
if peerName != nil {
|
||||
if name := strings.TrimSpace(*peerName); name != "" {
|
||||
|
||||
@@ -61,7 +61,7 @@ func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redactNode(&document, nil)
|
||||
redactNode(&document, nil, newAliasGuard())
|
||||
|
||||
out, err := yaml.Marshal(&document)
|
||||
if err != nil {
|
||||
@@ -120,7 +120,53 @@ func isSecretMapPath(path []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func redactNode(node *yaml.Node, path []string) {
|
||||
// Секрет, спрятанный за YAML-якорем, тоже покидал сервер.
|
||||
//
|
||||
// Что было. И redactNode, и redactSubtree разбирали четыре вида узлов:
|
||||
// документ, последовательность, отображение и скаляр. Пятый вид —
|
||||
// yaml.AliasNode — не обрабатывался ни одной из них, а yaml.v3 якоря и ссылки
|
||||
// поддерживает:
|
||||
//
|
||||
// shared: &credential VERY_SECRET_VALUE
|
||||
//
|
||||
// obfs:
|
||||
// type: salamander
|
||||
// salamander:
|
||||
// password: *credential
|
||||
//
|
||||
// Утечек здесь ДВЕ, и обе тихие. Значение под ключом `password` — это
|
||||
// AliasNode, и redactSubtree на нём был no-op. А сам якорь объявлен под
|
||||
// ключом `shared`, который секретоподобным не выглядит, поэтому и он
|
||||
// оставался нетронутым. В выгрузке секрет оказывался дважды.
|
||||
//
|
||||
// Канонический конфиг HY2XS якорей не содержит, но экспорт существует ещё и
|
||||
// для диагностики чужих и правленых руками файлов, а обещание «секреты не
|
||||
// покидают сервер» либо абсолютно, либо его нет.
|
||||
//
|
||||
// Лечится переходом ПО ССЫЛКЕ: alias ведёт к тому же узлу, что и якорь,
|
||||
// поэтому редакция цели закрывает оба вхождения сразу. Защита от циклов
|
||||
// обязательна — YAML допускает ссылку на предка, и обход без неё не завершится.
|
||||
|
||||
// aliasGuard помнит уже посещённые узлы обхода.
|
||||
type aliasGuard map[*yaml.Node]struct{}
|
||||
|
||||
func newAliasGuard() aliasGuard {
|
||||
return aliasGuard{}
|
||||
}
|
||||
|
||||
// enter возвращает false, если узел уже посещался в этом обходе.
|
||||
func (g aliasGuard) enter(node *yaml.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
if _, seen := g[node]; seen {
|
||||
return false
|
||||
}
|
||||
g[node] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func redactNode(node *yaml.Node, path []string, guard aliasGuard) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
@@ -128,18 +174,25 @@ func redactNode(node *yaml.Node, path []string) {
|
||||
switch node.Kind {
|
||||
case yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
redactNode(child, path)
|
||||
redactNode(child, path, guard)
|
||||
}
|
||||
|
||||
case yaml.AliasNode:
|
||||
// Обход продолжается по цели ссылки: путь остаётся прежним, потому что
|
||||
// alias стоит ровно там, где стояло бы само значение.
|
||||
if guard.enter(node) {
|
||||
redactNode(node.Alias, path, guard)
|
||||
}
|
||||
|
||||
case yaml.SequenceNode:
|
||||
for _, child := range node.Content {
|
||||
// Индекс не попадает в путь: правила формулируются по именам полей.
|
||||
redactNode(child, path)
|
||||
redactNode(child, path, guard)
|
||||
}
|
||||
|
||||
case yaml.MappingNode:
|
||||
if isSecretMapPath(path) {
|
||||
redactAllScalarValues(node)
|
||||
redactAllScalarValues(node, guard)
|
||||
return
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
@@ -148,10 +201,10 @@ func redactNode(node *yaml.Node, path []string) {
|
||||
childPath := append(append([]string{}, path...), key)
|
||||
|
||||
if isSecretKey(key) {
|
||||
redactSubtree(value)
|
||||
redactSubtree(value, guard)
|
||||
continue
|
||||
}
|
||||
redactNode(value, childPath)
|
||||
redactNode(value, childPath, guard)
|
||||
}
|
||||
|
||||
case yaml.ScalarNode:
|
||||
@@ -182,30 +235,40 @@ func sanitizeURLScalar(node *yaml.Node) {
|
||||
node.Style = 0
|
||||
}
|
||||
|
||||
func redactSubtree(node *yaml.Node) {
|
||||
func redactSubtree(node *yaml.Node, guard aliasGuard) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
setRedacted(node)
|
||||
case yaml.AliasNode:
|
||||
// Редактируется ЦЕЛЬ ссылки: она же является значением якоря, поэтому
|
||||
// одно действие закрывает и `*alias`, и место объявления `&anchor`.
|
||||
if guard.enter(node) {
|
||||
redactSubtree(node.Alias, guard)
|
||||
}
|
||||
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
|
||||
redactAllScalarValues(node)
|
||||
redactAllScalarValues(node, guard)
|
||||
}
|
||||
}
|
||||
|
||||
func redactAllScalarValues(node *yaml.Node) {
|
||||
func redactAllScalarValues(node *yaml.Node, guard aliasGuard) {
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
redactSubtree(node.Content[i+1])
|
||||
redactSubtree(node.Content[i+1], guard)
|
||||
}
|
||||
case yaml.SequenceNode, yaml.DocumentNode:
|
||||
for _, child := range node.Content {
|
||||
redactSubtree(child)
|
||||
redactSubtree(child, guard)
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
setRedacted(node)
|
||||
case yaml.AliasNode:
|
||||
if guard.enter(node) {
|
||||
redactSubtree(node.Alias, guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -232,6 +233,132 @@ func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Якоря и ссылки YAML ------------------------------------------------------
|
||||
|
||||
// Секрет, спрятанный за якорем, покидал сервер ДВАЖДЫ.
|
||||
//
|
||||
// redactNode и redactSubtree разбирали документ, последовательность,
|
||||
// отображение и скаляр, но не yaml.AliasNode. Значение под ключом `password`
|
||||
// было ссылкой — и redactSubtree на ней не делал ничего; сам якорь объявлен под
|
||||
// ключом `shared`, секретоподобным не выглядящим, — и его не трогал никто.
|
||||
//
|
||||
// Канонический конфиг HY2XS якорей не содержит, но выгрузка существует ещё и
|
||||
// для диагностики правленых руками файлов, а обещание «секреты не покидают
|
||||
// сервер» либо абсолютно, либо его нет.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredSecrets(t *testing.T) {
|
||||
raw := `shared: &credential VERY_SECRET_VALUE
|
||||
|
||||
listen: 0.0.0.0:443
|
||||
|
||||
obfs:
|
||||
type: salamander
|
||||
salamander:
|
||||
password: *credential
|
||||
`
|
||||
|
||||
sanitized := sanitizeForTest(t, raw)
|
||||
|
||||
if strings.Contains(sanitized, "VERY_SECRET_VALUE") {
|
||||
t.Fatalf("секрет за якорем уехал в выгрузку:\n%s", sanitized)
|
||||
}
|
||||
// Документ обязан остаться валидным YAML: ссылка без якоря его сломала бы.
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
||||
t.Fatalf("выгрузка перестала быть валидным YAML: %v\n%s", err, sanitized)
|
||||
}
|
||||
obfs, ok := parsed["obfs"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("секция obfs потеряна:\n%s", sanitized)
|
||||
}
|
||||
salamander, ok := obfs["salamander"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("подсекция salamander потеряна: %+v", obfs)
|
||||
}
|
||||
if salamander["password"] != RedactedPlaceholder {
|
||||
t.Fatalf("значение по ссылке не вырезано: %+v", salamander)
|
||||
}
|
||||
if parsed["shared"] != RedactedPlaceholder {
|
||||
t.Fatalf("объявление якоря осталось нетронутым: %+v", parsed["shared"])
|
||||
}
|
||||
}
|
||||
|
||||
// URL с учётными данными, спрятанный за якорем, — тот же случай.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredURLCredentials(t *testing.T) {
|
||||
raw := `shared: &endpoint https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
|
||||
|
||||
someFutureUpstreamFeature:
|
||||
endpoint: *endpoint
|
||||
`
|
||||
|
||||
sanitized := sanitizeForTest(t, raw)
|
||||
|
||||
for _, secret := range []string{"svc-p4ss", "endpoint-token"} {
|
||||
if strings.Contains(sanitized, secret) {
|
||||
t.Fatalf("секрет %q уехал в выгрузку через якорь:\n%s", secret, sanitized)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(sanitized, "relay.example.com") {
|
||||
t.Fatalf("адрес вырезан целиком вместо учётных данных:\n%s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Ссылка на составной узел: редактируется всё поддерево цели.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredMapping(t *testing.T) {
|
||||
raw := `shared: &creds
|
||||
username: proxyuser
|
||||
password: proxy-password
|
||||
|
||||
outbounds:
|
||||
- name: upstream
|
||||
type: socks5
|
||||
socks5: *creds
|
||||
`
|
||||
|
||||
sanitized := sanitizeForTest(t, raw)
|
||||
|
||||
if strings.Contains(sanitized, "proxy-password") {
|
||||
t.Fatalf("пароль в составном якоре уехал в выгрузку:\n%s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
// Ссылка на предка образует цикл. Без защиты обход не завершился бы вовсе —
|
||||
// это отказ обслуживания на выгрузке конфига, а не утечка.
|
||||
func TestSanitizeHysteria2ConfigYaml_SurvivesRecursiveAlias(t *testing.T) {
|
||||
raw := `root: &root
|
||||
password: cycle-secret
|
||||
self: *root
|
||||
`
|
||||
|
||||
type outcome struct {
|
||||
sanitized string
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
out, err := SanitizeHysteria2ConfigYaml(raw)
|
||||
done <- outcome{sanitized: string(out), err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
// yaml.v3 строит на такой записи ДЕЙСТВИТЕЛЬНО циклический граф узлов
|
||||
// (alias указывает на объемлющее отображение) и умеет его же обратно
|
||||
// сериализовать, поэтому отказ здесь был бы регрессией, а не
|
||||
// допустимым исходом.
|
||||
if got.err != nil {
|
||||
t.Fatalf("выгрузка отказала на рекурсивной ссылке: %v", got.err)
|
||||
}
|
||||
if strings.Contains(got.sanitized, "cycle-secret") {
|
||||
t.Fatalf("секрет в рекурсивной структуре уехал в выгрузку:\n%s", got.sanitized)
|
||||
}
|
||||
if !strings.Contains(got.sanitized, RedactedPlaceholder) {
|
||||
t.Fatalf("секрет исчез вместе со структурой вместо редакции:\n%s", got.sanitized)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("санитайзер зациклился на ссылке, указывающей на предка")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeURLValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||