18 Commits

Author SHA1 Message Date
Crimson b7e478f014 fix(rc5): harden frontend runtime and diagnostics 2026-09-08 05:22:06 +05:00
Crimson 079094591b fix(installer): harden admin smoke and rollback cleanup 2026-09-07 22:39:30 +05:00
Crimson bf10810cfc fix(build): remove pipefail-unsafe matchers 2026-09-07 20:34:50 +05:00
Crimson 6c286d1ff5 fix(build): align runtime env acceptance gate 2026-09-07 20:21:37 +05:00
Crimson a3445f79c5 fix(security): harden diagnostics storage boundary 2026-09-07 19:52:11 +05:00
Crimson af9f476658 fix(utf8): закрыть lossy-границы конфигурации и API 2026-09-07 17:25:27 +05:00
Crimson ab788725cf fix(env): контракт был шире домена, который принимает systemd
Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой
линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный
шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless
для значений, которые systemd принимает, но не спросил, какие значения он
принимает вообще.

1. Домен значений файла окружения

Перед тем как принять пару, 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, а сам
utf8_is_valid — встроенный NUL и невалидный UTF-8.

Пароль "abcde" + U+FDD0 — шесть символов, восемь байт, ни одного управляющего —
проходил панель, оркестратор, DTO и хеширование, записывался в hy2xs.env, и
после этого админка не поднималась. Тот же класс дефекта, ради уничтожения
которого контракт и существует, только слоем ниже.

Введён IsEnvTransportableText (Go) / isEnvTransportable (TS), повторяющий
множество systemd точно — не шире и не уже. Отдельно отвергаются одиночные
суррогаты: строка JavaScript вправе их содержать, а TextEncoder молча заменяет
непарный суррогат на U+FFFD, то есть без проверки в файл уехал бы ДРУГОЙ
секрет, а не отказ.

Заодно разделены домен транспорта и политика продукта. Проверка отвергала C0 и
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
двойных кавычек перевод строки накапливается как обычный байт и переживает
round-trip. Именно эта подмена и позволила проверке не знать про noncharacters.
Политика HY2XS теперь запрещает категорию Cc целиком (была шире кода ровно на
C1) плюс U+FEFF — последний отдельным решением продукта, а не форматом:
0xFEFF & 0xFFFE это 0xFEFE, и systemd такое значение принимает.

2. Рецепт восстановления выполнял env-файл как код

В docs/operations/12, раздел «Забыт пароль администратора», стояло
`set -a; . /etc/hy2xs/hy2xs.env; set +a`. Строка стала опасной ровно тогда,
когда файл научился нести произвольные значения. Для systemd
HY2XS_ADMIN_INITIAL_PASSWORD="$(...)" — буквальное значение: подстановок в
EnvironmentFile= нет вовсе. Но `.` обрабатывает файл bash, а bash внутри
двойных кавычек выполняет подстановку команд — от root, прямо в рецепте
восстановления доступа. Соседний раздел той же страницы при этом уже правильно
запрещал source/eval для bootstrap-admin.secret: документ запрещал действие и
тут же его предлагал.

Рецепт читает нужные значения как ДАННЫЕ. Поставлен гейт приёмки, запрещающий
возврат source/./eval над этими файлами в командах документации и в скриптах;
гейт смотрит только внутрь ```-блоков, чтобы объяснение, называющее убранную
конструкцию по имени, его не роняло.

3. Отказ приходил после мутаций хоста

Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала на
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов
и раскладки файловой системы, — а read-only preflight-install говорил PASS: он
зовёт parseRuntimeEnv и ничего не рендерит. Детерминированно известная ошибка
конфигурации роняла операцию, оставив за собой изменённый хост, что прямо
противоречит контракту PHASE 0.

validateRuntimeEnvTransport вызывается теперь из parseRuntimeEnv и проходит по
ВСЕМ парам runtimeEnvEntries: ограничение принадлежит формату, а не полю
пароля, и HY2XS_ADMIN_CON_PASS сломал бы загрузку юнита так же.

4. Точность порта автомата и его описания

- в состоянии DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не
  проверку на любой перевод строки (в VALUE_ESCAPE — наоборот,
  strchr(NEWLINE, c)). Порт съедал и \<LF>, и \<CR>;
- комментарий обещал одно намеренное расхождение с systemd, а их два: кроме
  строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла.
  Оба fail-closed и теперь названы оба.

Тесты: граничная таблица во всех слоях дополнена значениями вне домена
(U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8),
соседями диапазонов (U+FDCF, U+FDF0, U+FFFD, U+10FFFD), C1 и U+FEFF, одиночным
суррогатом. Добавлены TestEnvTransportDomainMatchesSystemd (домен не шире и не
уже) и TestProductPolicyIsWiderThanTransportDomain (домен и политика
различимы), а также проверки fail-closed порядка: parseRuntimeEnv отвергает
непригодную конфигурацию, проверяются все значения файла, запись и проверка
ходят по одному списку пар.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:38:57 +05:00
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом
bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от
64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль
длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как
утверждал комментарий, — так вела себя редакция пакета до v0.28.

Следствие: пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на
штатной смене пароля, а при установке падением старта админки, то есть
сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта
было то, что тест закреплял это значение как ожидаемое.

Вместе с ним закрыты три соседних расхождения того же контракта.

Пароль триммился вопреки собственному контракту. util.HashPassword вёл
проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал
strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение
"abcde " принимали все двери продукта и не мог захешировать никто, а первая
учётная запись создавалась не с тем паролем, который оператор записал в
hy2xs.env.

Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы
async-validator, а он сравнивает min/max с String.prototype.length: пароль из
трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера,
который панель не могла объяснить.

hy2xs.env не был форматом. Значения писались интерполяцией, а читались
split("=") с trim(); при этом файл читает не только оркестратор — он объявлен
EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd
срезает краевые пробелы и трактует обратный слеш как escape.

Что сделано:

- контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword
  и dao, а service импортирует util — обратный импорт был бы циклическим, и
  именно поэтому HashPassword завёл собственную копию правила;
- AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в
  оркестраторе и панели; сверяется тестами, читающими Go-исходник;
- одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в
  байтах тегом валидатора не выразить) и код причины admin_password_format,
  называющий обе границы;
- TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет
  контракт сам и падает с текстом, называющим переменную и файл;
- панель считает code points и UTF-8 байты общим adminPasswordFormRule на
  обеих формах вместо встроенных min/max;
- orchestrator/src/lib/envFile.ts — порт конечного автомата
  parse_env_file_internal из systemd и обратный ему кодировщик; экранируются
  только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные
  значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же
  кодировщиком пишется bootstrap-admin.secret;
- управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а
  ввести такой пароль в форму входа всё равно нельзя;
- отрицательная проба smoke сверяет конверт отказа (code 50000,
  invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль
  генерирует, а не берёт из литерала;
- положительная проба читает bootstrap-секрет парсером формата вместо
  grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы.

Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я»,
«abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами
инвертирован; round-trip env-формата на значениях с кавычками, слешами и
краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов
приёмки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:38:04 +05:00
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00
founder 82e5ca40cc fix(build): гейт ACME описывал снятую архитектуру панели
Приёмка требовала, чтобы страница Hysteria содержала жёсткий список ACME
DNS-провайдеров (cloudflare … vultr) и не содержала удалённого upstream
namedotcom. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор провайдера: список в
UI был вторым экземпляром upstream-реестра и мог от него отстать — ровно так
namedotcom и пришлось выпиливать вручную.

После перевода страницы в read-only диагностику реестра нет и быть не должно:
имя провайдера читается из фактического конфига и показывается как есть, поэтому
новый upstream-провайдер отображается без правок панели. Возврат списка ради
прохождения grep-а создал бы фиктивный реестр, существующий только для гейта.

Гейт проверяет действующий контракт: провайдер приходит строкой и рисуется как
значение, параметры DNS отдаются только именами, селектора на странице нет.
Проверено положительно и на трёх нарушениях (провайдер перестал показываться,
на странице появился селектор, тип стал перечислением) — гейт падает на каждом.

Контрактный тест панели дополнен обратной проверкой: ни одно из восьми имён
провайдеров не должно встречаться в исходнике страницы. Та же формулировка
поправлена в матрице приёмки.

Дополнительно прогнаны целиком все восемь функций приёмки, которым не нужен
распакованный пакет: других устаревших утверждений нет.
2026-09-03 23:07:19 +05:00
founder 7d486af712 fix(frontend): patch vulnerable browserslist dependency
pnpm audit по всему lock-графу остановил релизную сборку: browserslist@4.21.11
несёт high-advisory (уязвимы <= 4.28.6) и приходит транзитивно через
autoprefixer и update-browserslist-db.

Закрыто точечным pnpm.overrides на 4.28.7 — точной версией, а не диапазоном:
security-патч обязан быть детерминированным и не тащить за собой чужой major.
Обновилось только поддерево browserslist (caniuse-lite, electron-to-chromium,
escalade, node-releases, update-browserslist-db); autoprefixer, Vite и остальной
граф не тронуты.

Проверено с pnpm 9.15.9 (contract из packageManager):
  pnpm why browserslist        -> 4.28.7
  pnpm audit --audit-level high -> 0, no known vulnerabilities
  pnpm install --frozen-lockfile -> lockfile is up to date
  typecheck + build:prod        -> ok
2026-09-03 22:39:25 +05:00
founder b9d3c03f8d fix(admin): считать достижимым только тот адрес Traffic Stats API, который админка действительно опрашивает
Проверка принимала любой ip.IsLoopback(), то есть считала рабочим и 127.0.0.5.
Это неверно: слушатель на конкретном адресе принимает соединения только на него,
а слой proxy обращается строго к http://127.0.0.1:<port>.

  bind 127.0.0.5:38712  ->  dial 127.0.0.1:38712  ->  connection refused
  bind 0.0.0.0:38713    ->  dial 127.0.0.1:38713  ->  connected

Такой адрес выглядел локальным, ломал контур доступа целиком (лимит устройств
fail-closed => не подключается никто) и не вызывал у админки ни одного
возражения. Принимаются ровно 127.0.0.1, 0.0.0.0 и пустой хост.

IPv6-wildcard не принимается сознательно: соединение он принял бы, но HY2XS
объявлен IPv4-only, а зависеть в ответе «достучусь» от net.ipv6.bindv6only
нельзя.

На странице конфигурации мягкое состояние nonCanonicalLoopback убрано: прочий
loopback — это ошибка, а не предупреждение. Осталось три состояния: канон
профиля, wildcard, недостижим.

Свойство закреплено тестом с настоящими сокетами, а гейт приёмки запрещает
возврат IsLoopback() и требует негативного случая 127.0.0.5 в тестах.
2026-09-03 03:53:46 +05:00
founder b315001288 fix(admin): различать достижимость Traffic Stats API и соответствие профилю
Признак на странице конфигурации отвечал только на вопрос «достучится ли
админка», поэтому 0.0.0.0 показывался как норма — хотя внутренний control plane
при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации не
создаёт. Состояний теперь четыре: канон профиля, wildcard, не-канонический
loopback и недостижимый адрес.

Backend не тронут: он по-прежнему отвечает только на вопрос достижимости —
превращать лишнюю публикацию в отказ обслуживания значило бы отключить всех
пиров. Исправлено ложное утверждение в его комментарии: пустой хост `:36712` в
Go означает все интерфейсы, а не loopback.

Удалены мёртвые фразы common.wait/enableSuccess/disableSuccess — остатки
операций запуска, остановки и смены версии Hysteria, которых у панели нет.
2026-09-03 03:39:42 +05:00
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00
founder 8dcb50a07c fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков
Предыдущий проход сделал правильным порядок «сначала долговременная запись,
потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа
прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не
работал вовсе.

Импорт, заменивший auth_id: после неудавшегося /kick старое значение не
хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а
cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала
строку. Живая сессия оставалась навсегда.

Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет.
Лимит устройств в политику доступа не входит и входить не должен — это
свойство сессий, — поэтому механизма схождения у него не было.

enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из
/online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный
maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом
не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых
сессий уже есть, и это /online.

Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений
закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне
блокировки, поэтому снимки приходили в резервацию в произвольном порядке и
устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не
data race — память защищена мьютексом, и -race здесь молчит принципиально.
Последовательность «прочитать /online -> занять место» выполняется под замком
по authId; глобальный замок не годится, внутри идёт сетевой запрос.

Учёт разрешений больше не растёт бесконечно: запись снималась только на ветке
отказа, поэтому в карте копились удалённые пиры и переписанные импортом
идентификаторы. Уборка идёт по фактической картине подключений.

Гейты приёмки доращены под все три инварианта и проверены в обе стороны.
Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах,
где описывала снятую архитектуру.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
2026-09-02 07:15:43 +05:00
founder 6d1686b2be fix(admin): свести access-control к одному правилу и одному пути отзыва
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.

Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.

Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.

Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.

Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.

Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.

Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
2026-09-01 20:46:21 +05:00
founder 162759c599 fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
2026-09-01 17:17:17 +05:00
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00
205 changed files with 28767 additions and 6148 deletions
+980 -4
View File
File diff suppressed because it is too large Load Diff
+56 -19
View File
@@ -515,7 +515,7 @@ HY2XS_UI_PUBLIC_ACCESS=false
Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в Если PHASE 0 не прошла, установщик завершается с ошибкой и **сервер остаётся в
том же состоянии, в котором был**. HY2XS v1 не устанавливается поверх том же состоянии, в котором был**. 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‑пароль админки ### 10. Получите bootstrap‑пароль админки
@@ -634,8 +634,8 @@ hy2xs-orchestrator status \
| `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` | | `HY2XS_UI_BIND_HOST` | IPv4 bind HY2XS admin | `127.0.0.1` |
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` | | `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` | | `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора | `hy2xsadmin` | | `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` |
| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора; `__GENERATE__` генерируется при install | `__GENERATE__` | | `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_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` | | `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `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_BIND_HOST` | Bind Hysteria2. В production profile фиксируется на `0.0.0.0` | `0.0.0.0` |
| `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` | | `HY2XS_HYSTERIA_PORT` | UDP‑порт Hysteria2 | `443` |
| `HY2XS_HYSTERIA_AUTH_MODE` | Auth mode Hysteria2. Фиксированное значение production‑профиля | `http` | | `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_PORT` | Порт trafficStats API | `36712` |
| `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` | | `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET` | Secret для trafficStats и machine auth | `__GENERATE__` |
| `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` | | `HY2XS_HYSTERIA_OBFS_TYPE` | Тип обфускации: `gecko` или `salamander`. Смена меняет wire‑совместимость | `gecko` |
@@ -749,7 +749,7 @@ hy2xs-orchestrator reconfigure \
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения | | `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию | | `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** | | `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 перед публикацией логов | | `hy2xs-orchestrator redact-config` | Отредактировать секреты в env/yaml перед публикацией логов |
`repair` без `--allow-partial-state` работает только поверх полностью успешной `repair` без `--allow-partial-state` работает только поверх полностью успешной
@@ -766,6 +766,22 @@ hy2xs-orchestrator diagnostics collect \
--config /etc/hy2xs/hy2xs.env --config /etc/hy2xs/hy2xs.env
``` ```
Бандл не содержит сырых промежуточных копий конфигурации или журналов:
редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не
декодируется с заменой и не попадает в архив; вместо него записывается
безопасная причина пропуска.
`HY2XS_FORCE_PASSWORD_CHANGE` — диагностический boolean, поэтому его значение
`true`/`false` сохраняется. Исключение точное и не распространяется на другие
ключи с `PASSWORD`: начальный пароль администратора, `ADMIN_CON_PASS`, пароль
obfs и остальные секреты по-прежнему заменяются на `<redacted>`.
Архив создаётся в `/var/lib/hy2xs/diagnostics` с режимом `0600`. Этот каталог
принадлежит `root:root`, имеет режим `0700` и отделён от
`HY2XS_LOG_DIR`, которым владеет сервисный пользователь `hy2xs-admin`.
Оркестратор отказывает, если каталог подменён symlink, имеет другого владельца
или ослабленные права. Незавершённый staging-каталог после упаковки удаляется.
## Проверка безопасности после установки ## Проверка безопасности после установки
Минимальный набор проверок: Минимальный набор проверок:
@@ -818,7 +834,7 @@ HY2XS v1 не поддерживает установку поверх и не
Что делать: Что делать:
1. сохраните нужные данные (база пиров, конфиг) — см. 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`; 2. посмотрите план очистки: `sudo ./purge-v0.sh`;
3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`; 3. выполните очистку: `sudo ./purge-v0.sh --apply --yes-i-know`;
4. повторите установку. 4. повторите установку.
@@ -928,9 +944,9 @@ HY2XS development environment
contract: versions.env (HY2XS 1.0.0, release line 1) contract: versions.env (HY2XS 1.0.0, release line 1)
Go: Go:
required: 1.26.7 required: 1.26.8
found: 1.25.6 found: 1.25.6
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.7 FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.8
Node: Node:
required: 24.20.0 required: 24.20.0
@@ -997,22 +1013,34 @@ export GITHUB_TOKEN=<token>
1. проверяет контракт `versions.env` (`verify_versions_contract`); 1. проверяет контракт `versions.env` (`verify_versions_contract`);
2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`); 2. прогоняет тесты и типы оркестратора (`bun test`, `tsc --noEmit`);
3. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт; 3. прогоняет dependency-free контракты панели (спрайт иконок, совпадение словарей, коды ошибок, атрибуция и frontend/Go-контракты);
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; 4. определяет последнюю стабильную версию Hysteria, берёт ожидаемый SHA‑256 из upstream `hashes.txt` и сверяет с ним скачанный артефакт;
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; 5. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
6. прогоняет `go vet` и `go test` для HY2XS admin; 6. собирает standalone-бинарник orchestrator;
7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); 7. устанавливает frontend lock-граф, runtime-компилирует каждое сообщение RU/EN реальным `vue-i18n`, затем проверяет типы и собирает frontend и backend, проставляя версию админки из контракта;
8. формирует архив и прогоняет acceptance‑проверки. 8. прогоняет `go vet` и `go test` для HY2XS admin;
9. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
10. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–7 останавливает сборку до создания пакета. Любой сбой на шагах 1–9 останавливает сборку до создания пакета.
Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка Тесты и типы (шаги 2, 3, 7 и 8) — такой же обязательный гейт, как проверка
зависимостей: переменной, которая их отключает, не существует. Готовый пакет зависимостей: переменной, которая их отключает, не существует. Готовый пакет
объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это
утверждение опирается на фактический прогон, а не на намерение. утверждение опирается на фактический прогон, а не на намерение.
Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`, Для локальной работы обходить нечего: `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 не создают.
Runtime-проверка словарей требует установленного frontend lock-графа:
```bash
cd apps/frontend
pnpm install --frozen-lockfile
bun test test/i18n-runtime.test.ts
```
Переменные, управляющие выбором версии Hysteria: Переменные, управляющие выбором версии Hysteria:
@@ -1087,9 +1115,16 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
├── package/ # skeleton будущего install package ├── package/ # skeleton будущего install package
├── tools/build/ # production builder и packaging pipeline ├── tools/build/ # production builder и packaging pipeline
├── tools/dev/ # doctor: сверка среды разработки с versions.env ├── tools/dev/ # doctor: сверка среды разработки с versions.env
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria ├── tools/test/ # e2e с реальным клиентом Hysteria и контракты панели
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения ├── 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 ├── versions.env # контракт продукта, платформы и toolchain
├── CHANGELOG.md ├── CHANGELOG.md
├── README.md ├── README.md
@@ -1098,6 +1133,8 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
Каталог `dist/` создаётся builder’ом и не должен храниться в git. Каталог `dist/` создаётся builder’ом и не должен храниться в git.
Точка входа в документацию — [docs/README.md](docs/README.md).
## Для кого этот проект ## Для кого этот проект
HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу. HY2XS рассчитан на операторов, которым нужен воспроизводимый способ поставить Hysteria2‑сервер с локальной панелью управления, не собирая проект на production‑сервере и не открывая admin UI наружу.
+7 -3
View File
@@ -142,13 +142,17 @@ func classifyServeError(err error) error {
return errors.New("start server err") return errors.New("start server err")
} }
// releaseResource закрывает то, чем владеет сам процесс админки.
//
// Вызова service.ReleaseHysteria2() здесь больше нет. Функция была пустой
// заглушкой `return nil` — остатком модели, в которой панель считала Hysteria
// своим подпроцессом и обязана была его отпустить. Жизненным циклом Hysteria
// владеет systemd, отпускать админке нечего, и шаг завершения, который ничего
// не делает, но выглядит освобождением ресурса, хуже отсутствующего.
func releaseResource() { func releaseResource() {
if err := dao.CloseSqliteDB(); err != nil { if err := dao.CloseSqliteDB(); err != nil {
logrus.Errorf("%v", err) logrus.Errorf("%v", err)
} }
if err := service.ReleaseHysteria2(); err != nil {
logrus.Errorf("%v", err)
}
} }
func initFile() error { func initFile() error {
+39
View File
@@ -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)
}
+838
View File
@@ -0,0 +1,838 @@
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/middleware"
"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)
}
// postLoginThroughFilter воспроизводит реальную внешнюю дверь login API:
// scanner filter выполняется раньше DTO и контроллера. Именно этой связки не
// было в тестах до RC3, поэтому backend и smoke были зелёными по отдельности,
// а настоящий installer получал 403 на стандартный curl User-Agent.
func postLoginThroughFilter(t *testing.T, body any, userAgent string) (int, apiResult) {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("не удалось собрать тело запроса: %v", err)
}
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(gin.Recovery(), middleware.FilterHandler())
engine.POST("/api/auth/login", Login)
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", userAgent)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var result apiResult
if recorder.Body.Len() > 0 {
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("middleware вернул не JSON: %s", recorder.Body.String())
}
}
return recorder.Code, result
}
// Полный wire-path RC3: стандартный UA curl действительно блокируется, а
// выделенный UA установщика проходит тот же middleware до authentication logic.
func TestLoginWirePathRespectsScannerFilterAndInstallerUserAgent(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
status, blocked := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}, "curl/8.10.1")
if status != http.StatusForbidden || blocked.Code != http.StatusForbidden {
t.Fatalf("scanner-like curl не заблокирован: HTTP %d, ответ %+v", status, blocked)
}
status, accepted := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "bootstrap-password",
}, "HY2XS-Installer/1.0")
if status != http.StatusOK || accepted.Code != constant.CodeSuccess {
t.Fatalf("UA установщика не дошёл до успешного входа: HTTP %d, ответ %+v", status, accepted)
}
var issued struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(accepted.Data, &issued); err != nil || issued.AccessToken == "" {
t.Fatalf("успешная wire-проба не выдала токен: %s", string(accepted.Data))
}
_, rejected := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"pass": "wrong-password",
}, "HY2XS-Installer/1.0")
var invalidCredentials bool
for _, reason := range rejected.Errors {
invalidCredentials = invalidCredentials || reason.Code == constant.ErrCodeInvalidCredentials
}
if rejected.Code != constant.CodeSysError || !invalidCredentials {
t.Fatalf("negative wire-проба не дошла до auth logic: %+v", rejected)
}
}
// `password` не является скрытым alias: иначе orchestrator и frontend могли бы
// незаметно разойтись по двум разным HTTP-контрактам.
func TestLoginWirePathRejectsPasswordAlias(t *testing.T) {
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
_, result := postLoginThroughFilter(t, map[string]any{
"username": "hy2xsadmin",
"password": "bootstrap-password",
}, "HY2XS-Installer/1.0")
var passRequired bool
for _, reason := range result.Errors {
passRequired = passRequired || (reason.Field == "pass" && reason.Code == constant.ErrCodeRequired)
}
if result.Code != constant.CodeInvalidError || !passRequired {
t.Fatalf("alias password не отклонён как отсутствие wire-поля pass: %+v", result)
}
}
// Регрессия 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)
}
}
+23 -2
View File
@@ -187,13 +187,34 @@ func ListConfig(c *gin.Context) {
vo.Success(configVos, c) 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) { func GetHysteria2Config(c *gin.Context) {
config, err := service.GetHysteria2Config() profile, err := service.BuildHysteria2Profile()
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
vo.Success(config, c) vo.Success(profile, c)
} }
// ExportHysteria2Config отдаёт оператору фактический серверный конфиг. // ExportHysteria2Config отдаёт оператору фактический серверный конфиг.
+2
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
) )
@@ -29,6 +30,7 @@ type apiResult struct {
Code int `json:"code"` Code int `json:"code"`
Type string `json:"type"` Type string `json:"type"`
Message string `json:"message"` Message string `json:"message"`
Errors []vo.FieldError `json:"errors"`
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
} }
+24
View File
@@ -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)
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func Hysteria2Auth(c *gin.Context) { func Hysteria2Auth(c *gin.Context) {
var req dto.Hysteria2AuthDto var req dto.Hysteria2AuthDto
if err := c.ShouldBindJSON(&req); err != nil { if err := strictBindJSON(c, &req); err != nil {
vo.Hysteria2AuthBadRequest(c) vo.Hysteria2AuthBadRequest(c)
return return
} }
+113
View File
@@ -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
}
+31
View File
@@ -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)
}
})
}
}
+33 -6
View File
@@ -17,10 +17,20 @@ func LogSystem(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
exists := util.Exists(constant.SystemLogPath)
logSystemVos := make([]vo.LogSystemVo, 0) 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 return
} }
numLine := 0 numLine := 0
@@ -38,9 +48,26 @@ func LogSystem(c *gin.Context) {
continue continue
} }
logSystemVo := vo.LogSystemVo{} 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 continue
} }
// Собственный журнал санитизируется так же, как чужой. // Собственный журнал санитизируется так же, как чужой.
+134
View File
@@ -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)
}
}
+59 -32
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"strconv" "strconv"
@@ -18,31 +19,31 @@ import (
"hy2xs-admin/service" "hy2xs-admin/service"
) )
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
//
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
// вызывала validateField, который читает тело запроса, а обработчик следом
// читает то же тело второй раз — gin его не буферизует, и второй разбор
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
// когда понадобился бы.
func resolveID(c *gin.Context) (int64, error) { func resolveID(c *gin.Context) (int64, error) {
if raw := strings.TrimSpace(c.Param("id")); raw != "" { raw := strings.TrimSpace(c.Param("id"))
parsed, err := strconv.ParseInt(raw, 10, 64) parsed, err := strconv.ParseInt(raw, 10, 64)
if err == nil && parsed > 0 { if err != nil || parsed <= 0 {
return parsed, nil 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{}) return parsed, nil
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)
} }
func PagePeer(c *gin.Context) { func PagePeer(c *gin.Context) {
@@ -50,12 +51,12 @@ func PagePeer(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
records, total, err := service.PagePeer(peerPageDto) records, total, onlineState, err := service.PagePeer(peerPageDto)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return 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) { func SavePeer(c *gin.Context) {
@@ -65,7 +66,7 @@ func SavePeer(c *gin.Context) {
} }
peerVo, err := service.CreatePeer(peerSaveDto) peerVo, err := service.CreatePeer(peerSaveDto)
if err != nil { if err != nil {
vo.Fail(err.Error(), c) failService(err, c)
return return
} }
vo.Success(peerVo, c) vo.Success(peerVo, c)
@@ -76,8 +77,14 @@ func DeletePeer(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
// failService, а не vo.Fail: удаление умеет завершиться ЧАСТИЧНО — пир
// отключён в базе, но завершить его активную сессию не удалось, поэтому
// строка намеренно оставлена на месте. Через vo.Fail этот результат уехал
// бы панели неотличимо от полного отказа, и оператор сделал бы неверный
// вывод: «удаление не сработало, пир как был», — тогда как доступ уже
// закрыт, а строка ждёт повторной попытки.
if err = service.DeletePeer(id); err != nil { if err = service.DeletePeer(id); err != nil {
vo.Fail(err.Error(), c) failService(err, c)
return return
} }
vo.Success(nil, c) vo.Success(nil, c)
@@ -100,12 +107,12 @@ func UpdatePeer(c *gin.Context) {
return return
} }
if taken { if taken {
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c) failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
return return
} }
} }
if err = service.UpdatePeer(id, peerUpdateDto); err != nil { if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
vo.Fail(err.Error(), c) failService(err, c)
return return
} }
vo.Success(nil, c) vo.Success(nil, c)
@@ -158,7 +165,15 @@ func ImportPeer(c *gin.Context) {
return return
} }
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") { 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 return
} }
@@ -174,6 +189,10 @@ func ImportPeer(c *gin.Context) {
vo.Fail("the file is too big", c) vo.Fail("the file is too big", c)
return return
} }
if err = validateStrictJSONEncoding(content); err != nil {
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
return
}
var peerExports []bo.PeerExport var peerExports []bo.PeerExport
decoder := json.NewDecoder(bytes.NewReader(content)) decoder := json.NewDecoder(bytes.NewReader(content))
@@ -198,8 +217,12 @@ func ImportPeer(c *gin.Context) {
return return
} }
// failService, а не vo.Fail: импорт умеет завершиться ЧАСТИЧНО — партия
// зафиксирована в базе целиком, но завершить старые сессии обновлённых
// пиров не удалось. Полный отказ здесь означал бы для оператора «файл не
// применился», хотя он применился весь.
if err = service.UpsertPeerExport(peerExports); err != nil { if err = service.UpsertPeerExport(peerExports); err != nil {
vo.Fail(err.Error(), c) failService(err, c)
return return
} }
vo.Success(nil, c) vo.Success(nil, c)
@@ -255,8 +278,12 @@ func KickPeer(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
// failService, а не vo.Fail: отзыв доступа умеет завершиться ЧАСТИЧНО —
// блокировка записана, активную сессию завершить не удалось. Через
// vo.Fail этот результат уезжал бы панели неотличимо от полного отказа, и
// оператор сделал бы неверный вывод о состоянии пира.
if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil { if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil {
vo.Fail(err.Error(), c) failService(err, c)
return return
} }
vo.Success(nil, c) vo.Success(nil, c)
+20 -2
View File
@@ -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() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
if err != nil { if err != nil {
t.Fatalf("не удалось собрать форму: %v", err) t.Fatalf("не удалось собрать форму: %v", err)
} }
if _, err := part.Write([]byte(content)); err != nil { if _, err := part.Write(content); err != nil {
t.Fatalf("не удалось записать файл в форму: %v", err) t.Fatalf("не удалось записать файл в форму: %v", err)
} }
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
return result 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 читает ПЕРВЫЙ документ и останавливается. Файл с // Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая // хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
// половина файла молча не применялась. // половина файла молча не применялась.
+548
View File
@@ -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)
}
}
+233 -19
View File
@@ -1,47 +1,261 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"reflect"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"net/http" "hy2xs-admin/service"
"regexp"
) )
var validate *validator.Validate var validate *validator.Validate
func init() { func init() {
validate = validator.New() 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 { func mustRegister(tag string, fn validator.Func) {
field := f.Field().String() if err := validate.RegisterValidation(tag, fn); err != nil {
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$" }
compile := regexp.MustCompile(reg)
return field == "" || compile.MatchString(field)
} }
// 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) { func validateField[T interface{}](c *gin.Context, field T) (T, error) {
var bindErr error var bindErr error
if c.Request.Method == http.MethodGet { switch c.Request.Method {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field) bindErr = c.ShouldBindQuery(&field)
} else if c.Request.Method == http.MethodPost || case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
c.Request.Method == http.MethodPut || bindErr = strictBindJSON(c, &field)
c.Request.Method == http.MethodPatch ||
c.Request.Method == http.MethodDelete {
bindErr = c.ShouldBindJSON(&field)
} }
if bindErr != nil { if bindErr != nil {
vo.Fail(constant.InvalidError, c) vo.FailValidation(
return field, fmt.Errorf(constant.InvalidError) "запрос не разобран: проверьте формат и типы полей",
[]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 { if err := validate.Struct(&field); err != nil {
vo.Fail(constant.InvalidError, c) vo.FailValidation(
return field, fmt.Errorf(constant.InvalidError) "проверка данных не пройдена",
describeValidationErrors(err),
c,
)
return field, errors.New(constant.ErrCodeValidationFailed)
} }
return field, nil 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
}
+101
View File
@@ -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)
}
}
}
+236
View File
@@ -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
}
+269
View File
@@ -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("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
}
}
+104
View File
@@ -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 байта не проходит проверку собственным хешем")
}
}
+33 -1
View File
@@ -3,11 +3,13 @@ package dao
import ( import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
"hy2xs-admin/credential"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/util" "hy2xs-admin/util"
@@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error {
return nil 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 == "" { if adminPassword == "" {
return errors.New( return errors.New(
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" + "HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
@@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error {
"`hy2xs-orchestrator repair --allow-partial-state`.", "`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) forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
status := int64(1) status := int64(1)
tokenVersion := int64(1) tokenVersion := int64(1)
@@ -709,6 +740,7 @@ func tableExists(tableName string) bool {
} }
return count > 0 return count > 0
} }
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ // ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
// придумывает его сам. // придумывает его сам.
// //
+7 -1
View File
@@ -19,5 +19,11 @@ Frontend собирается production builder'ом из [`tools/build`](../..
```sh ```sh
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm run build:prod bun test test/i18n-runtime.test.ts
pnpm run verify
``` ```
Runtime-тест обходит все строковые сообщения русского и английского словарей
через установленный `vue-i18n`. Он проверяет не только исключения, но и
compiler diagnostics в `console.error`: некоторые версии компилятора сообщают
ошибку формата без исключения из `t()`.
+11 -3
View File
@@ -57,9 +57,17 @@ export default tseslint.config(
...globals.browser, ...globals.browser,
...globals.node, ...globals.node,
...autoImportGlobals, ...autoImportGlobals,
// Объявлены в src/types/global.d.ts и используются как глобальные типы. // Объявлены в src/types/global.d.ts и используются как глобальные
DialogType: "readonly", // типы. Списку положено совпадать с содержимым того файла: `no-undef`
OptionType: "readonly", // не знает о declare global, и отсутствующее здесь имя падает ошибкой
// линтера при совершенно корректном коде.
//
// DialogType и OptionType отсюда убраны: в src/types/global.d.ts их
// нет и в панели они не встречаются — это остаток чужого шаблона,
// разрешавший необъявленные имена.
IdDto: "readonly",
BaseDto: "readonly",
PageVo: "readonly",
}, },
}, },
rules: { rules: {
+1 -11
View File
@@ -28,7 +28,7 @@
"qrcode.vue": "3.4.1", "qrcode.vue": "3.4.1",
"vue": "^3.5.42", "vue": "^3.5.42",
"vue-echarts": "^8.1.0", "vue-echarts": "^8.1.0",
"vue-i18n": "^9.14.5", "vue-i18n": "^11.4.10",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
@@ -65,16 +65,6 @@
"vue-eslint-parser": "10.4.1", "vue-eslint-parser": "10.4.1",
"vue-tsc": "^3.3.11" "vue-tsc": "^3.3.11"
}, },
"pnpm": {
"overrides": {
"lodash": ">=4.18.1",
"lodash-es": ">=4.18.1",
"brace-expansion": ">=1.1.18",
"picomatch": ">=2.3.2",
"js-yaml": ">=4.3.1",
"table>ajv": ">=8.18.0"
}
},
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
+81 -59
View File
@@ -4,14 +4,6 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
brace-expansion: '>=1.1.18'
picomatch: '>=2.3.2'
js-yaml: '>=4.3.1'
table>ajv: '>=8.18.0'
importers: importers:
.: .:
@@ -53,8 +45,8 @@ importers:
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3)) version: 8.1.0(echarts@6.1.0)(vue@3.5.42(typescript@5.9.3))
vue-i18n: vue-i18n:
specifier: ^9.14.5 specifier: ^11.4.10
version: 9.14.5(vue@3.5.42(typescript@5.9.3)) version: 11.4.10(vue@3.5.42(typescript@5.9.3))
vue-router: vue-router:
specifier: ^4.6.4 specifier: ^4.6.4
version: 4.6.4(vue@3.5.42(typescript@5.9.3)) version: 4.6.4(vue@3.5.42(typescript@5.9.3))
@@ -491,17 +483,21 @@ packages:
'@iconify/utils@3.1.4': '@iconify/utils@3.1.4':
resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
'@intlify/core-base@9.14.5': '@intlify/core-base@11.4.10':
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==} resolution: {integrity: sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==}
engines: {node: '>= 16'} engines: {node: '>= 22'}
'@intlify/message-compiler@9.14.5': '@intlify/devtools-types@11.4.10':
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==} resolution: {integrity: sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==}
engines: {node: '>= 16'} engines: {node: '>= 22'}
'@intlify/shared@9.14.5': '@intlify/message-compiler@11.4.10':
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==} resolution: {integrity: sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==}
engines: {node: '>= 16'} engines: {node: '>= 22'}
'@intlify/shared@11.4.10':
resolution: {integrity: sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==}
engines: {node: '>= 22'}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1175,6 +1171,11 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} 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: boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -1186,8 +1187,8 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
browserslist@4.21.11: browserslist@4.28.7:
resolution: {integrity: sha512-xn1UXOKUz7DjdGlg9RrUr0GGiWzI97UQJnugHtH0OLDfJB7jMgoIkYvRIEO1l9EeEERVqeqLYOcFBW9ldjypbQ==} resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
@@ -1209,6 +1210,9 @@ packages:
caniuse-lite@1.0.30001539: caniuse-lite@1.0.30001539:
resolution: {integrity: sha512-hfS5tE8bnNiNvEOEkm8HElUHroYwlqMMENEzELymy77+tJ6m+gA2krtHl5hxJaj71OlpC2cHZbdSMX1/YEqEkA==} resolution: {integrity: sha512-hfS5tE8bnNiNvEOEkm8HElUHroYwlqMMENEzELymy77+tJ6m+gA2krtHl5hxJaj71OlpC2cHZbdSMX1/YEqEkA==}
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
chalk@2.4.2: chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1343,8 +1347,8 @@ packages:
echarts@6.1.0: echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
electron-to-chromium@1.4.529: electron-to-chromium@1.5.420:
resolution: {integrity: sha512-6uyPyXTo8lkv8SWAmjKFbG42U073TXlzD4R8rW3EzuznhFS2olCIAfjjQtV2dV2ar/vRF55KUd3zQYnCB0dd3A==} resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==}
element-plus@2.14.5: element-plus@2.14.5:
resolution: {integrity: sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==} resolution: {integrity: sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==}
@@ -1390,8 +1394,8 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
escalade@3.1.1: escalade@3.2.0:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-string-regexp@1.0.5: escape-string-regexp@1.0.5:
@@ -1516,7 +1520,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
peerDependencies: peerDependencies:
picomatch: '>=2.3.2' picomatch: ^3 || ^4
peerDependenciesMeta: peerDependenciesMeta:
picomatch: picomatch:
optional: true optional: true
@@ -1780,8 +1784,8 @@ packages:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies: peerDependencies:
'@types/lodash-es': '*' '@types/lodash-es': '*'
lodash: '>=4.18.1' lodash: '*'
lodash-es: '>=4.18.1' lodash-es: '*'
lodash.truncate@4.4.2: lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
@@ -1865,8 +1869,9 @@ packages:
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-releases@2.0.13: node-releases@2.0.54:
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
normalize-path@3.0.0: normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -1947,6 +1952,10 @@ packages:
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.7: picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2392,8 +2401,8 @@ packages:
webpack: webpack:
optional: true optional: true
update-browserslist-db@1.0.13: update-browserslist-db@1.3.2:
resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
@@ -2473,10 +2482,9 @@ packages:
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
vue-i18n@9.14.5: vue-i18n@11.4.10:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==} resolution: {integrity: sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==}
engines: {node: '>= 16'} engines: {node: '>= 22'}
deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
peerDependencies: peerDependencies:
vue: ^3.0.0 vue: ^3.0.0
@@ -2772,17 +2780,23 @@ snapshots:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
import-meta-resolve: 4.2.0 import-meta-resolve: 4.2.0
'@intlify/core-base@9.14.5': '@intlify/core-base@11.4.10':
dependencies: dependencies:
'@intlify/message-compiler': 9.14.5 '@intlify/devtools-types': 11.4.10
'@intlify/shared': 9.14.5 '@intlify/message-compiler': 11.4.10
'@intlify/shared': 11.4.10
'@intlify/message-compiler@9.14.5': '@intlify/devtools-types@11.4.10':
dependencies: dependencies:
'@intlify/shared': 9.14.5 '@intlify/core-base': 11.4.10
'@intlify/shared': 11.4.10
'@intlify/message-compiler@11.4.10':
dependencies:
'@intlify/shared': 11.4.10
source-map-js: 1.2.1 source-map-js: 1.2.1
'@intlify/shared@9.14.5': {} '@intlify/shared@11.4.10': {}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
@@ -3435,7 +3449,7 @@ snapshots:
autoprefixer@10.4.13(postcss@8.5.26): autoprefixer@10.4.13(postcss@8.5.26):
dependencies: dependencies:
browserslist: 4.21.11 browserslist: 4.28.7
caniuse-lite: 1.0.30001539 caniuse-lite: 1.0.30001539
fraction.js: 4.3.6 fraction.js: 4.3.6
normalize-range: 0.1.2 normalize-range: 0.1.2
@@ -3455,6 +3469,8 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
baseline-browser-mapping@2.11.20: {}
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@5.0.9: brace-expansion@5.0.9:
@@ -3465,12 +3481,13 @@ snapshots:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
browserslist@4.21.11: browserslist@4.28.7:
dependencies: dependencies:
caniuse-lite: 1.0.30001539 baseline-browser-mapping: 2.11.20
electron-to-chromium: 1.4.529 caniuse-lite: 1.0.30001810
node-releases: 2.0.13 electron-to-chromium: 1.5.420
update-browserslist-db: 1.0.13(browserslist@4.21.11) node-releases: 2.0.54
update-browserslist-db: 1.3.2(browserslist@4.28.7)
cac@7.0.0: {} cac@7.0.0: {}
@@ -3491,6 +3508,8 @@ snapshots:
caniuse-lite@1.0.30001539: {} caniuse-lite@1.0.30001539: {}
caniuse-lite@1.0.30001810: {}
chalk@2.4.2: chalk@2.4.2:
dependencies: dependencies:
ansi-styles: 3.2.1 ansi-styles: 3.2.1
@@ -3609,7 +3628,7 @@ snapshots:
tslib: 2.3.0 tslib: 2.3.0
zrender: 6.1.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)): element-plus@2.14.5(vue@3.5.42(typescript@5.9.3)):
dependencies: dependencies:
@@ -3686,7 +3705,7 @@ snapshots:
'@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2 '@esbuild/win32-x64': 0.28.2
escalade@3.1.1: {} escalade@3.2.0: {}
escape-string-regexp@1.0.5: {} escape-string-regexp@1.0.5: {}
@@ -4098,7 +4117,7 @@ snapshots:
micromatch@4.0.8: micromatch@4.0.8:
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
picomatch: 4.0.7 picomatch: 2.3.2
mime-db@1.52.0: {} mime-db@1.52.0: {}
@@ -4134,7 +4153,7 @@ snapshots:
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
node-releases@2.0.13: {} node-releases@2.0.54: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
@@ -4230,6 +4249,8 @@ snapshots:
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.2: {}
picomatch@4.0.7: {} picomatch@4.0.7: {}
pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)): pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)):
@@ -4733,11 +4754,11 @@ snapshots:
rollup: 4.63.1 rollup: 4.63.1
vite: 7.3.6(jiti@2.7.0)(sass@1.103.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: dependencies:
browserslist: 4.21.11 browserslist: 4.28.7
escalade: 3.1.1 escalade: 3.2.0
picocolors: 1.0.0 picocolors: 1.1.1
uri-js@4.4.1: uri-js@4.4.1:
dependencies: dependencies:
@@ -4783,10 +4804,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vue-i18n@9.14.5(vue@3.5.42(typescript@5.9.3)): vue-i18n@11.4.10(vue@3.5.42(typescript@5.9.3)):
dependencies: dependencies:
'@intlify/core-base': 9.14.5 '@intlify/core-base': 11.4.10
'@intlify/shared': 9.14.5 '@intlify/devtools-types': 11.4.10
'@intlify/shared': 11.4.10
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
vue: 3.5.42(typescript@5.9.3) vue: 3.5.42(typescript@5.9.3)
+13
View File
@@ -0,0 +1,13 @@
# Настройки resolution принадлежат pnpm, а не npm package manifest.
# Единственный workspace-проект — сам frontend.
packages:
- "."
overrides:
lodash: ">=4.18.1"
lodash-es: ">=4.18.1"
brace-expansion: ">=1.1.18"
picomatch: ">=2.3.2"
js-yaml: ">=4.3.1"
"table>ajv": ">=8.18.0"
browserslist: "4.28.7"
@@ -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;
}
+6 -2
View File
@@ -4,7 +4,7 @@ import {
ConfigsDto, ConfigsDto,
ConfigUpdateDto, ConfigUpdateDto,
ConfigVo, ConfigVo,
Hysteria2ServerConfig, Hysteria2ProfileVo,
} from "@/api/config/types"; } from "@/api/config/types";
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку: // Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
@@ -18,7 +18,11 @@ import {
// таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через // таблицы и не вызывался ни из одного экрана. Чтение настроек идёт через
// listConfigApi, на стороне сервера — по allowlist. // listConfigApi, на стороне сервера — по allowlist.
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> { // Ответ описывает production-профиль, а не всю модель серверного конфига: он
// отличает «не задано» от значения и отдельно перечисляет секции вне профиля.
// Раньше сюда приезжала внутренняя модель целиком — вместе с паролем
// обфускации, токенами ACME DNS и учётными данными outbound-прокси.
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ProfileVo> {
return request({ return request({
url: "/config/getHysteria2Config", url: "/config/getHysteria2Config",
method: "get", method: "get",
+92 -177
View File
@@ -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 { export interface Hysteria2ProfileVo {
listen: string; listen: string | null;
tls?: { auth: Hysteria2ProfileAuth | null;
cert: string; tls: Hysteria2ProfileTls | null;
key: string; acme: Hysteria2ProfileAcme | null;
sniGuard?: string; obfs: Hysteria2ProfileObfs | null;
clientCA?: string; bandwidth: Hysteria2ProfileBandwidth | null;
}; ignoreClientBandwidth: boolean | null;
ech?: { congestion: Hysteria2ProfileCongestion | null;
keyPath?: string; quic: Hysteria2ProfileQuic | null;
}; trafficStats: Hysteria2ProfileTrafficStats | null;
acme?: { /** Секции файла, которых production-профиль не описывает. */
domains: string[]; drift: 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 Hysteria2ServerConfigOutbound { export interface Hysteria2ProfileAuth {
name: string; type: string | null;
type: string; /** Адрес backend-auth без machine token: он вырезан на сервере. */
socks5?: { url: string | null;
addr: string; insecure: boolean | null;
username?: string;
password?: string;
};
http?: {
url: string;
insecure: boolean;
};
direct?: {
mode: string;
bindIPv4?: string;
bindIPv6?: string;
bindDevice?: string;
fastOpen?: boolean;
};
} }
export interface Tab { export interface Hysteria2ProfileTls {
name: string; cert: string | null;
desc: string; 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;
} }
+16
View File
@@ -18,7 +18,23 @@ export interface DashboardSummaryVo {
}; };
hysteria: { hysteria: {
version: string; version: string;
/**
* Состояние службы по systemd.
*
* `unknown` — это НЕ «остановлена»: `systemctl is-active` может не
* ответить вовсе, и тогда о службе не известно ничего. Пока состояние было
* булевым, второе выдавалось за первое, и оператор шёл перезапускать
* работающий туннель.
*/
serviceState: "active" | "inactive" | "unknown";
/** Ровно `serviceState === "active"`. */
running: boolean; running: boolean;
/**
* Ответил ли Traffic Stats API на фактическое обращение.
*
* Независим от `serviceState`: раньше выводился из него и мог утверждать
* «API доступен» при «служба остановлена», ни разу не сходив в API.
*/
apiReachable: boolean; apiReachable: boolean;
lastApiError?: string; lastApiError?: string;
}; };
+6 -2
View File
@@ -8,7 +8,11 @@ export interface LogExportDto {
export interface LogSystemVo { export interface LogSystemVo {
clientIp: string; clientIp: string;
latencyTime: string; // Числа объявлены числами: сервер шлёт их из vo.LogSystemVo как int64
// (latencyTime, statusCode), а не строками. Пока обе колонки не
// отображались, расхождение было безвредным; после того как строка журнала
// стала читаемой, оно стало бы обычной ошибкой сравнения или форматирования.
latencyTime: number;
level: string; level: string;
msg: string; msg: string;
reqMethod: string; reqMethod: string;
@@ -17,7 +21,7 @@ export interface LogSystemVo {
reqPath: string; reqPath: string;
// Только имена переданных query-параметров, без значений. // Только имена переданных query-параметров, без значений.
reqQueryKeys: string; reqQueryKeys: string;
statusCode: string; statusCode: number;
time: string; time: string;
} }
+31 -1
View File
@@ -4,6 +4,7 @@ import {
KickPeerDto, KickPeerDto,
PeerClientConfigVo, PeerClientConfigVo,
PeerPageDto, PeerPageDto,
PeerPageVo,
PeerSaveDto, PeerSaveDto,
PeerUpdateDto, PeerUpdateDto,
PeerVo, PeerVo,
@@ -16,15 +17,20 @@ export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
}); });
} }
// Форма пира показывает причины отказа под своими полями, поэтому общий тост
// ей не нужен: он повторял бы то же самое вторым сигналом.
export function savePeerApi(data: PeerSaveDto): AxiosPromise { export function savePeerApi(data: PeerSaveDto): AxiosPromise {
return request({ return request({
url: "/peers", url: "/peers",
method: "post", method: "post",
data, data,
skipErrorToast: true,
}); });
} }
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> { // Ответ страницы описан собственным типом, а не общим PageVo<PeerVo>: кроме
// записей он несёт признак того, известна ли вообще картина подключений.
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PeerPageVo> {
return request({ return request({
url: "/peers", url: "/peers",
method: "get", method: "get",
@@ -32,10 +38,20 @@ export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
}); });
} }
// Действия над строкой пира сообщают свой исход сами (см. reportPeerActionError
// в списке пиров), поэтому общий тост им не нужен: он был бы вторым сигналом о
// том же событии.
//
// Для отзыва доступа это не косметика. Операция умеет завершиться ЧАСТИЧНО —
// запрет новых подключений записан, активную сессию завершить не удалось, — и
// страница показывает такой исход предупреждением. Общий перехватчик показал бы
// рядом ошибку, то есть два противоречащих друг другу сообщения об одном
// результате.
export function deletePeerApi(data: IdDto): AxiosPromise { export function deletePeerApi(data: IdDto): AxiosPromise {
return request({ return request({
url: `/peers/${data.id}`, url: `/peers/${data.id}`,
method: "delete", method: "delete",
skipErrorToast: true,
}); });
} }
@@ -44,6 +60,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
url: `/peers/${data.id}`, url: `/peers/${data.id}`,
method: "patch", method: "patch",
data, data,
skipErrorToast: true,
}); });
} }
@@ -51,6 +68,7 @@ export function resetPeerTrafficApi(data: IdDto): AxiosPromise {
return request({ return request({
url: `/peers/${data.id}/reset-traffic`, url: `/peers/${data.id}/reset-traffic`,
method: "patch", method: "patch",
skipErrorToast: true,
}); });
} }
@@ -58,6 +76,7 @@ export function releaseKickPeerApi(data: IdDto): AxiosPromise {
return request({ return request({
url: `/peers/${data.id}/release-kick`, url: `/peers/${data.id}/release-kick`,
method: "patch", method: "patch",
skipErrorToast: true,
}); });
} }
@@ -66,6 +85,7 @@ export function kickPeerApi(id: number, data: KickPeerDto): AxiosPromise {
url: `/peers/${id}/kick`, url: `/peers/${id}/kick`,
method: "patch", method: "patch",
data, data,
skipErrorToast: true,
}); });
} }
@@ -78,6 +98,15 @@ export function getPeerClientConfigApi(
}); });
} }
// Импорт сообщает свой исход сам — по той же причине, что и действия строки
// пира.
//
// Партия применяется одной транзакцией, а после её фиксации завершаются старые
// сессии обновлённых пиров. Второй шаг умеет не удаться отдельно от первого, и
// тогда ответ несёт peer_disconnect_failed: файл применён целиком, но часть
// клиентов остаётся на связи до переподключения. Общий перехватчик показал бы
// такой исход красной ошибкой, то есть сообщил бы оператору ровно обратное
// тому, что произошло.
export function importPeerApi(data: FormData): AxiosPromise { export function importPeerApi(data: FormData): AxiosPromise {
return request({ return request({
url: "/peer-import", url: "/peer-import",
@@ -86,6 +115,7 @@ export function importPeerApi(data: FormData): AxiosPromise {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
data, data,
skipErrorToast: true,
}); });
} }
+23 -1
View File
@@ -62,9 +62,31 @@ export type PeerVo = {
onlineDevices: number; 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 { export interface PeerClientConfigVo {
url: string; url: string;
qrCode?: string | Uint8Array;
} }
export interface KickPeerDto { export interface KickPeerDto {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+18 -9
View File
@@ -1,33 +1,42 @@
<template> <template>
<svg <svg
aria-hidden="true" aria-hidden="true"
focusable="false"
class="svg-icon" class="svg-icon"
:style="'width:' + size + ';height:' + size" :style="'width:' + size + ';height:' + size"
> >
<use :xlink:href="symbolId" :fill="color" /> <use :xlink:href="symbolId" />
</svg> </svg>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SYMBOL_PREFIX } from "./symbol";
/**
* Цвет иконке не передаётся — и это контракт, а не упущение.
*
* Раньше здесь были проп `color` и `:fill="color"` на `<use>`. Ими никто не
* пользовался ни разу, а существование такого пропа приглашает чинить
* сломанный цвет точечно: «вот этой иконке передадим белый». Монохромная
* иконка обязана получать цвет ровно одним способом — наследованием
* `currentColor` от компонента и темы; ассет, который так не умеет, чинится в
* самом ассете и не доезжает до релиза (см. `symbol.ts`).
*
* Префикс id тоже больше не проп: он принадлежит спрайту, а не месту вызова, и
* объявлен рядом с кодом, который этот id создаёт.
*/
const props = defineProps({ const props = defineProps({
prefix: {
type: String,
default: "icon",
},
iconClass: { iconClass: {
type: String, type: String,
required: false, required: false,
}, },
color: {
type: String,
},
size: { size: {
type: String, type: String,
default: "1em", default: "1em",
}, },
}); });
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`); const symbolId = computed(() => `#${SYMBOL_PREFIX}-${props.iconClass}`);
</script> </script>
<style scoped> <style scoped>
+6 -63
View File
@@ -20,9 +20,14 @@
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных * Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в * иконок это несколько килобайт, и они не стоят неисправимой зависимости в
* сборке. * сборке.
*
* Преобразование файла в `<symbol>` и контракт ассета живут в `./symbol.ts`:
* там нет ни Vite, ни DOM, поэтому те же правила проверяются тестом и
* релизным гейтом, а не только глазами на живой странице.
*/ */
const SYMBOL_PREFIX = "icon"; import { iconName, toSymbol } from "./symbol";
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__"; const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых // eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
@@ -33,68 +38,6 @@ const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
eager: true, 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. * содержимое, а не добавляет второй элемент с теми же 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>
+17
View File
@@ -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;
+171
View File
@@ -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[],
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Контракт имени пира на стороне панели.
*
* Сервер остаётся конечным авторитетом: эти значения зеркалят константы из
* apps/service/peer_import.go и сверяются с ними release-тестом. Общий модуль
* не даёт форме, сообщению об ошибке и подсказке завести разные копии правила.
*/
export const PEER_NAME_MIN_LENGTH = 6;
export const PEER_NAME_MAX_LENGTH = 32;
/** Набор знаков без букв и цифр — передаётся в i18n как значение. */
export const PEER_NAME_PUNCTUATION = "!@#$%^&*()_+-=";
/** Полный набор в том виде, в каком его показывают оператору. */
export const PEER_NAME_CHARSET = `a-z A-Z 0-9 ${PEER_NAME_PUNCTUATION}`;
/**
* Набор символов в записи регулярного выражения.
*
* Дефис экранирован намеренно: неэкранированное `+-=` становится диапазоном и
* принимает лишние знаки. Числовые границы берутся из тех же констант, что и
* тексты формы.
*/
const PEER_NAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+\\-=";
export const PEER_NAME_PATTERN = new RegExp(
`^[${PEER_NAME_CHARACTER_CLASS}]{${PEER_NAME_MIN_LENGTH},${PEER_NAME_MAX_LENGTH}}$`
);
+145 -216
View File
@@ -21,8 +21,13 @@ export default {
password: "Password", password: "Password",
login: "Login", login: "Login",
capsLockOn: "Caps lock is On", 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: { dashboard: {
stale: "Dashboard data is stale. Retrying automatically...", stale: "Dashboard data is stale. Retrying automatically...",
@@ -44,8 +49,19 @@ export default {
download: "Download", download: "Download",
upload: "Upload", upload: "Upload",
total: "Total", total: "Total",
serviceStateLabel: "Hysteria service",
serviceState: {
active: "Running",
inactive: "Stopped",
unknown: "State unknown",
},
trafficApiLabel: "Traffic Stats API",
trafficApiReachable: "Reachable",
trafficApiUnreachable: "Unreachable",
security: { security: {
hysteriaStopped: "Hysteria2 is stopped", hysteriaStopped: "Hysteria2 is stopped",
hysteriaStateUnknown:
"Hysteria2 service state is unknown: systemd did not answer",
trafficApiUnavailable: "Traffic API is unavailable", trafficApiUnavailable: "Traffic API is unavailable",
}, },
error: { error: {
@@ -90,9 +106,9 @@ export default {
save: "Save", save: "Save",
update: "Update", update: "Update",
downloadSuccess: "Download successful", downloadSuccess: "Download successful",
wait: "The version is being changed, please wait a moment", // `wait`, `enableSuccess` and `disableSuccess` are gone together with the
enableSuccess: "Hysteria2 start successful", // operations the panel does not have: starting, stopping and changing the
disableSuccess: "Hysteria2 stop successful", // version of Hysteria. That lifecycle belongs to systemd and the orchestrator.
success: "Success", success: "Success",
refresh: "Refresh", refresh: "Refresh",
yes: "Yes", yes: "Yes",
@@ -112,7 +128,77 @@ export default {
invalid: "Invalid value", invalid: "Invalid value",
switchLanguageSuccess: "Language switched successfully", switchLanguageSuccess: "Language switched successfully",
logoutConfirm: "Are you sure you want to log out?", 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: { info: {
expireTime: "y-M-d H:m:s", expireTime: "y-M-d H:m:s",
@@ -128,12 +214,14 @@ export default {
remark: "Remark", remark: "Remark",
secret: "Secret", secret: "Secret",
form: { 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: 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.", "Peer identifier: {min} to {max} characters — latin letters, digits and {punctuation}. 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", remarkPlaceholder: "laptop",
remarkHint: "Optional operator note. It is never shown to the client.", remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically", secretPlaceholder: "leave empty to generate one",
secretHint: secretHint:
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.", "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.", quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
@@ -168,16 +256,31 @@ export default {
onlineStatus: "Online Status", onlineStatus: "Online Status",
online: "Online", online: "Online",
offline: "Offline", 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", device: "Online Devices",
unit: "Unit",
loginAt: "Last login time", loginAt: "Last login time",
conAt: "Last connection time", conAt: "Last connection time",
createTime: "Create Time", createTime: "Create Time",
releaseSuccess: "Release successful", releaseSuccess: "Release successful",
kick: "Kick", kick: "Ban temporarily",
kickTip: "Force user to log off", kickTip:
releaseKick: "Release", "Drop the session and refuse new connections until the ban expires",
releaseKickTip: "Remove offline status", 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", createdAt: "Created at",
bannedUntil: "Banned until", bannedUntil: "Banned until",
totalTraffic: "Total traffic", totalTraffic: "Total traffic",
@@ -192,22 +295,15 @@ export default {
resetTrafficMonth: "Run once a month, midnight, first of month", resetTrafficMonth: "Run once a month, midnight, first of month",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun", resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
}, },
monitor: { // The `monitor` section is gone together with its only consumer — the
cpuPercent: "CPU Usage", // "Hysteria2 Status" tag on the config page. The service state has three
memPercent: "Memory Usage", // values, not two, and its phrases live in `dashboard.serviceState`.
diskPercent: "Disk Usage",
hysteria2UserTotal: "Number of online users",
hysteria2DeviceTotal: "Number of online devices",
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
hysteria2RunningTrue: "Running",
hysteria2RunningFalse: "Stop",
},
log: { log: {
numLine: "Number of lines", numLine: "Number of lines",
level: "Level", level: "Level",
message: "Message", message: "Message",
time: "Time", time: "Time",
exportFailed: "Could not export the log",
}, },
errorPage: { errorPage: {
back: "Back", back: "Back",
@@ -222,201 +318,34 @@ export default {
notFoundBackHome: "Back to home", notFoundBackHome: "Back to home",
}, },
hysteria: { hysteria: {
enable: "Enable",
disable: "Disable",
addConfigItem: "Add Config Item",
hysteria2Version: "Hysteria2 Version", hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status", ownedByOrchestrator: "The Hysteria configuration is owned by the installer",
outboundsEmpty: "No outbounds are configured on the server", ownedByOrchestratorHint:
listen: "Listen", "The panel shows /etc/hysteria/config.yaml read-only. Changes are applied by `hy2xs-orchestrator reconfigure`.",
listen: "Listen address",
auth: "Peer authentication",
tls: "TLS", tls: "TLS",
obfs: "Obfuscation", obfs: "Obfuscation",
quic: "QUIC parameters", quic: "QUIC parameters",
bandwidth: "Bandwidth", bandwidth: "Bandwidth",
congestion: "Congestion control", congestion: "Congestion control",
speedTest: "Speed Test", trafficStats: "Traffic Stats API",
udp: "UDP", notSet: "not set",
resolver: "Resolver", sectionMissing: "The section is absent from the configuration",
sniff: "Protocol Sniffing", tlsMissing: "Neither tls nor acme is configured",
acl: "ACL", valuesHidden: "values are not shown",
outbounds: "Outbounds", secretSet: "set",
http: "Traffic Stats API (HTTP)", secretMissing: "not set",
masquerade: "Masquerade", obfsPasswordHint: "handed out in the peer share link",
config: { driftTitle: "Configuration drift",
listen: driftHint:
"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.", "The file contains sections outside the HY2XS production profile. The orchestrator neither creates nor supports them: the configuration was most likely edited by hand.",
tlsType: "TLS type", trafficStatsMissing: "Traffic Stats API is not configured",
tls: { trafficStatsMissingHint:
cert: "The path to the Cert file.", "Without the trafficStats section the device limit, traffic accounting and forced disconnect do not work: the panel reaches Hysteria only through this API.",
key: "The path to the Key file.", trafficStatsUnreachable:
sniGuard: "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",
'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.', trafficStatsWildcard:
}, "the API is reachable but listens on every interface. The HY2XS production profile uses 127.0.0.1: run hy2xs-orchestrator reconfigure",
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.",
},
},
}, },
}; };
+178 -171
View File
@@ -19,8 +19,24 @@ export default {
password: "Пароль", password: "Пароль",
login: "Войти", login: "Войти",
capsLockOn: "Caps Lock включён", 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: { dashboard: {
stale: stale:
@@ -43,8 +59,21 @@ export default {
download: "Скачано", download: "Скачано",
upload: "Отдано", upload: "Отдано",
total: "Всего", total: "Всего",
serviceStateLabel: "Служба Hysteria",
// Три состояния, а не два: «спросить systemd не удалось» — это не
// «служба остановлена», и действия оператора у них разные.
serviceState: {
active: "Работает",
inactive: "Остановлена",
unknown: "Состояние неизвестно",
},
trafficApiLabel: "Traffic Stats API",
trafficApiReachable: "Доступен",
trafficApiUnreachable: "Недоступен",
security: { security: {
hysteriaStopped: "Hysteria2 остановлена", hysteriaStopped: "Hysteria2 остановлена",
hysteriaStateUnknown:
"Состояние службы Hysteria2 неизвестно: systemd не ответил",
trafficApiUnavailable: "Traffic API недоступен", trafficApiUnavailable: "Traffic API недоступен",
}, },
error: { error: {
@@ -87,9 +116,9 @@ export default {
save: "Сохранить", save: "Сохранить",
update: "Обновить", update: "Обновить",
downloadSuccess: "Загрузка завершена", downloadSuccess: "Загрузка завершена",
wait: "Версия меняется, подождите", // Фразы `wait`, `enableSuccess` и `disableSuccess` удалены вместе с
enableSuccess: "Hysteria2 запущена", // операциями, которых у панели нет: запуском, остановкой и сменой версии
disableSuccess: "Hysteria2 остановлена", // Hysteria. Жизненный цикл принадлежит systemd и оркестратору.
success: "Готово", success: "Готово",
refresh: "Обновить", refresh: "Обновить",
yes: "Да", yes: "Да",
@@ -109,7 +138,88 @@ export default {
invalid: "Некорректное значение", invalid: "Некорректное значение",
switchLanguageSuccess: "Язык переключён", switchLanguageSuccess: "Язык переключён",
logoutConfirm: "Выйти из системы?", 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: { info: {
expireTime: "г-М-д Ч:м:с", expireTime: "г-М-д Ч:м:с",
@@ -124,12 +234,16 @@ export default {
remark: "Комментарий", remark: "Комментарий",
secret: "Секрет", secret: "Секрет",
form: { form: {
namePlaceholder: "например, ivan-laptop", namePlaceholder: "client-01",
// Набор символов назван так, как его понимает service.IsValidPeerName.
// Прежняя формулировка «латиница, цифры и дефис» осталась от правила,
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
// сервер принимает, и оператор не имел причин пробовать остальные.
nameHint: nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", "Идентификатор пира: от {min} до {max} символов — латиница, цифры и знаки {punctuation}. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: апример, Ноутбук Ивана, отдел продаж", remarkPlaceholder: оутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.", remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически", secretPlaceholder: "оставьте пустым — сгенерируем",
secretHint: secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.", "Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.", quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
@@ -164,21 +278,35 @@ export default {
onlineStatus: "Онлайн", onlineStatus: "Онлайн",
online: "Онлайн", online: "Онлайн",
offline: "Офлайн", offline: "Офлайн",
onlineUnknown: "Онлайн неизвестен",
onlineUnavailable: "Картина подключений сейчас недоступна",
onlineUnavailableHint:
"Traffic Stats API Hysteria не ответил, поэтому онлайн и число устройств неизвестны. Сохранённое состояние пиров в таблице верно.",
device: "Устройства", device: "Устройства",
createdAt: "Создан", createdAt: "Создан",
bannedUntil: "Блокировка до", bannedUntil: "Блокировка до",
totalTraffic: "Суммарный трафик", totalTraffic: "Суммарный трафик",
copyUri: "Копировать URI", copyUri: "Копировать URI",
more: "Ещё", more: "Ещё",
unit: "Ед. изм.",
loginAt: "Последний вход", loginAt: "Последний вход",
conAt: "Последнее подключение", conAt: "Последнее подключение",
createTime: "Создано", createTime: "Создано",
releaseSuccess: "Ограничение снято", releaseSuccess: "Ограничение снято",
kick: "Отключить", kick: "Временно заблокировать",
kickTip: "Принудительно отключить пользователя", kickTip: "Прервать соединение и не пускать до истечения срока",
releaseKick: "Снять", kickConfirm:
releaseKickTip: "Снять офлайн-статус", "Заблокировать пир «{name}» до {until}? Текущее соединение будет разорвано, новые подключения до этого времени приниматься не будут.",
kickSuccess: "Пир временно заблокирован",
releaseKick: "Снять блокировку",
releaseKickTip: "Снять временную блокировку досрочно",
// Отключение и временная блокировка — разные механизмы: у блокировки есть
// срок, который истекает сам, отключение снимается только руками.
enablePeer: "Включить пир",
disablePeer: "Отключить пир",
disableConfirm:
"Отключить пир «{name}»? Текущее соединение будет разорвано, а новые подключения запрещены, пока пир не будет включён обратно.",
enableSuccess: "Пир включён",
disableSuccess: "Пир отключён",
}, },
config: { config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure", orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
@@ -188,22 +316,16 @@ export default {
resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
}, },
monitor: { // Раздел `monitor` удалён вместе со своим единственным потребителем —
cpuPercent: "CPU", // плашкой «Статус Hysteria2» на странице конфигурации. Состояние службы
memPercent: "Память", // имеет три значения, а не два, и его фразы живут в `dashboard.serviceState`:
diskPercent: "Диск", // «остановлена» и «неизвестно» — разные ответы оператору.
hysteria2UserTotal: "Пользователей онлайн",
hysteria2DeviceTotal: "Устройств онлайн",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
hysteria2RunningTrue: "Работает",
hysteria2RunningFalse: "Остановлена",
},
log: { log: {
numLine: "Количество строк", numLine: "Количество строк",
level: "Уровень", level: "Уровень",
message: "Сообщение", message: "Сообщение",
time: "Время", time: "Время",
exportFailed: "Не удалось выгрузить журнал",
}, },
errorPage: { errorPage: {
back: "Назад", back: "Назад",
@@ -218,157 +340,42 @@ export default {
notFoundBackHome: "На главную", notFoundBackHome: "На главную",
}, },
hysteria: { hysteria: {
enable: "Включить",
disable: "Отключить",
addConfigItem: "Добавить параметр",
hysteria2Version: "Версия Hysteria2", hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2", ownedByOrchestrator: "Конфигурацией Hysteria владеет установщик",
// addOutbound удалён вместе с редактором outbounds: страница read-only, ownedByOrchestratorHint:
// маршрутов записи серверного конфига в API нет. "Панель показывает файл /etc/hysteria/config.yaml только на чтение. Изменения вносит `hy2xs-orchestrator reconfigure`.",
outboundsEmpty: "Outbounds в конфигурации сервера не заданы", // Разделы production-профиля. Экран показывает то, что записано в файле,
// и отдельно называет секции сверх профиля: универсального редактора всех
// возможностей Hysteria здесь нет намеренно — конфиг создаёт оркестратор.
listen: "Адрес прослушивания", listen: "Адрес прослушивания",
auth: "Авторизация пиров",
tls: "TLS", tls: "TLS",
obfs: "Маскировка", obfs: "Маскировка",
quic: "Параметры QUIC", quic: "Параметры QUIC",
bandwidth: "Полоса", bandwidth: "Полоса",
congestion: "Congestion control", congestion: "Congestion control",
speedTest: "Тест скорости", trafficStats: "Traffic Stats API",
udp: "UDP", notSet: "не задано",
resolver: "DNS", sectionMissing: "Секция в конфигурации отсутствует",
sniff: "Sniffing протоколов", tlsMissing: "Ни tls, ни acme в конфигурации не заданы",
acl: "ACL", valuesHidden: "значения не показываются",
outbounds: "Outbounds", secretSet: "задан",
http: "Traffic Stats API (HTTP)", secretMissing: "не задан",
masquerade: "Masquerade", obfsPasswordHint: "выдаётся в клиентской ссылке пира",
config: { driftTitle: "Расхождение конфигурации",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.", driftHint:
tlsType: "Тип TLS", "В файле есть секции вне production-профиля HY2XS. Оркестратор их не создаёт и не поддерживает: скорее всего, конфигурацию правили вручную.",
tls: { trafficStatsMissing: "Traffic Stats API не настроен",
cert: "Путь к cert-файлу", trafficStatsMissingHint:
key: "Путь к key-файлу", "Без секции trafficStats не работают лимит устройств, учёт трафика и принудительное отключение: панель обращается к Hysteria только через этот API.",
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.", // Две разные новости об одном поле, и объединять их нельзя: первая
}, // означает, что доступ пиров уже не работает, вторая — что API работает, но
acme: { // опубликован шире необходимого. Прочие адреса loopback (127.0.0.5 и
domains: "Домены", // подобные) относятся к ПЕРВОЙ: слушатель на конкретном адресе соединения
email: "Email", // на 127.0.0.1 не принимает.
ca: "CA: letsencrypt или zerossl", trafficStatsUnreachable:
listenHost: "Адрес для ACME challenge", "панель не может обратиться по этому адресу: Traffic Stats API запрашивается строго через 127.0.0.1. Пока адреса расходятся, лимит устройств, учёт трафика и принудительное отключение не работают",
dir: "Каталог ACME аккаунта и сертификатов", trafficStatsWildcard:
type: "Тип ACME challenge: http, tls или dns", "API доступен, но слушает все интерфейсы. Production-профиль HY2XS использует 127.0.0.1: выполните hy2xs-orchestrator reconfigure",
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",
},
},
}, },
}; };
+16 -9
View File
@@ -28,17 +28,24 @@ const { isFullscreen, toggle } = useFullscreen();
/** /**
* Выход из системы. * Выход из системы.
*
* Отмена диалога — это ОТВЕТ оператора, а не ошибка. ElMessageBox отклоняет
* промис при нажатии «Отмена», и цепочка без разбора отказа оставляла в
* консоли необработанное отклонение промиса на каждый передумавший выход.
*/ */
function logout() { async function logout() {
ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), { try {
confirmButtonText: t("navbar.logout"), await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
cancelButtonText: t("common.cancel"), confirmButtonText: t("navbar.logout"),
type: "warning", cancelButtonText: t("common.cancel"),
}).then(() => { type: "warning",
adminStore.logout().then(() => {
router.push(`/login?redirect=${route.fullPath}`);
}); });
}); } catch {
return;
}
await adminStore.logout();
router.push(`/login?redirect=${route.fullPath}`);
} }
</script> </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 SidebarItem from "./SidebarItem.vue";
import Logo from "./Logo.vue"; import Logo from "./Logo.vue";
import Footer from "./Footer.vue";
import { usePermissionStore } from "@/store/modules/permission"; import { usePermissionStore } from "@/store/modules/permission";
import { useAppStore } from "@/store/modules/app"; import { useAppStore } from "@/store/modules/app";
@@ -36,5 +37,6 @@ const route = useRoute();
/> />
</el-menu> </el-menu>
</el-scrollbar> </el-scrollbar>
<Footer :collapse="!appStore.sidebar.opened" />
</div> </div>
</template> </template>
+11 -1
View File
@@ -38,12 +38,22 @@
height: 100%; height: 100%;
} }
// Область прокрутки меню ограничена сверху логотипом, снизу — подписью
// разработчика. Пункты меню поэтому не могут наехать на подпись даже при
// длинном списке: им физически некуда.
&.has-logo { &.has-logo {
.el-scrollbar { .el-scrollbar {
height: calc(100% - 50px); height: calc(100% - 50px - #{$sidebarFooterHeight});
} }
} }
.sidebar-footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
.is-horizontal { .is-horizontal {
display: none; display: none;
} }
+7
View File
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
$sideBarWidth: 210px; $sideBarWidth: 210px;
$sideBarCollapsedWidth: 54px; $sideBarCollapsedWidth: 54px;
// Высота подписи разработчика внизу бокового меню.
//
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
$sidebarFooterHeight: 34px;
+4 -6
View File
@@ -17,6 +17,8 @@ declare module 'vue' {
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] 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'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
@@ -37,14 +39,12 @@ declare module 'vue' {
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] 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'] ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
Hamburger: typeof import('./../components/Hamburger/index.vue')['default'] Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
@@ -53,14 +53,12 @@ declare module 'vue' {
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default'] IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
IEpRefresh: typeof import('~icons/ep/refresh')['default'] IEpRefresh: typeof import('~icons/ep/refresh')['default']
IEpUserFilled: typeof import('~icons/ep/user-filled')['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'] 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'] Pagination: typeof import('./../components/Pagination/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default'] SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
} }
export interface GlobalDirectives { export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+121
View File
@@ -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;
}
+73
View File
@@ -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");
}
+6 -70
View File
@@ -23,73 +23,9 @@ export const formatBytes = (bytes: number, decimals = 2): string => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}; };
export const calculateBytes = (value = 0, unit = "Bytes"): number => { // calculateBytes, formatStorageCapacity и formatStorageUnit удалены вместе с
// Приведение единицы к верхнему регистру и удаление пробелов // компонентом UnitSelect, который был их единственным потребителем.
const formattedUnit = unit.toUpperCase().trim(); //
// Сам UnitSelect не использовался ни одной страницей панели: квота
// Маппинг единиц хранения на количество байт // вводится обычным полем в байтах, а показывается через formatBytes. Компонент
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];
};
+73
View File
@@ -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);
}
}
+123 -26
View File
@@ -1,6 +1,12 @@
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios"; import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import { useAdminStoreHook } from "@/store/modules/admin"; import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index"; 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__ || ""; const dynamicBase = (window as any).__dynamic_base__ || "";
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI: // Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
@@ -10,6 +16,25 @@ const dynamicBase = (window as any).__dynamic_base__ || "";
// ADMIN_API_BASE в оркестраторе. // ADMIN_API_BASE в оркестраторе.
const API_BASE = "/api"; const API_BASE = "/api";
const t = i18n.global.t; const t = i18n.global.t;
/**
* Запрос может отказаться от общего тоста, если показывает причину сам.
*
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
* второй сигнал тостом там только шумит. Так же поступают действия над строкой
* пира: отзыв доступа умеет завершиться частично, и такой исход показывается
* предупреждением, а не ошибкой.
*
* Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code`
* и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего,
* и молчание о нём означало бы операцию, не происходящую без объяснений.
*/
declare module "axios" {
export interface AxiosRequestConfig {
skipErrorToast?: boolean;
}
}
// Создание axios instance // Создание axios instance
const service = axios.create({ const service = axios.create({
baseURL: `${dynamicBase}${API_BASE}`, 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 // Response interceptor
service.interceptors.response.use( service.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
const { code, message } = response.data; // Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
if (code === 20000) { // проверяться ДО обращения к его полям: у Blob их нет.
return response.data; if (
} response.data instanceof ArrayBuffer ||
// Обработка бинарного ответа при экспорте файлов response.data instanceof Blob
if (response.data instanceof ArrayBuffer || response.data instanceof Blob) { ) {
return response; return response;
} }
ElMessage.error(message || "Системная ошибка"); const payload = response.data as ApiErrorPayload;
return Promise.reject(new Error(message || "Error")); if (payload?.code === API_CODE.success) {
}, return response.data;
(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 || "Системная ошибка");
}
} }
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 { useRoute, useRouter } from "vue-router";
import { adminChangePasswordApi } from "@/api/admin"; import { adminChangePasswordApi } from "@/api/admin";
import { useAdminStore } from "@/store/modules/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 { t } = useI18n();
const route = useRoute(); const route = useRoute();
@@ -46,7 +52,33 @@ const form = reactive({
newPassword: "", 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 = { const rules: FormRules = {
oldPassword: [ oldPassword: [
{ {
@@ -54,11 +86,7 @@ const rules: FormRules = {
message: t("common.required"), message: t("common.required"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
{ { ...passwordRule },
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
], ],
newPassword: [ newPassword: [
{ {
@@ -66,11 +94,7 @@ const rules: FormRules = {
message: t("common.required"), message: t("common.required"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
{ { ...passwordRule },
pattern: passwordPattern,
message: t("common.invalid"),
trigger: ["change", "blur"],
},
], ],
}; };
+71 -6
View File
@@ -36,6 +36,37 @@
class="mb-2" 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-row :gutter="10" class="mt-2">
<el-col :xs="24" :sm="12" :lg="6" <el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never" ><el-card shadow="never"
@@ -58,16 +89,26 @@
>{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card >{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card
></el-col ></el-col
> >
<!--
Пока Traffic Stats API недоступен, «0 онлайн» не данные, а
утверждение, которого никто не проверял. Раньше именно так и было:
сборщик метрик получал пустую картину подключений из ответа systemctl и
показывал ноль рядом с плашкой «служба остановлена».
-->
<el-col :xs="24" :sm="12" :lg="6" <el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never" ><el-card shadow="never"
>{{ $t("dashboard.onlinePeers") }}: >{{ $t("dashboard.onlinePeers") }}:
{{ summary.peers.onlinePeers }}</el-card {{
summary.hysteria.apiReachable ? summary.peers.onlinePeers : "?"
}}</el-card
></el-col ></el-col
> >
<el-col :xs="24" :sm="12" :lg="6" <el-col :xs="24" :sm="12" :lg="6"
><el-card shadow="never" ><el-card shadow="never"
>{{ $t("dashboard.onlineDevices") }}: >{{ $t("dashboard.onlineDevices") }}:
{{ summary.peers.onlineDevices }}</el-card {{
summary.hysteria.apiReachable ? summary.peers.onlineDevices : "?"
}}</el-card
></el-col ></el-col
> >
<el-col :xs="24" :sm="12" :lg="6" <el-col :xs="24" :sm="12" :lg="6"
@@ -89,9 +130,9 @@
<div class="chart-header"> <div class="chart-header">
<span>{{ $t("dashboard.trafficChart") }}</span> <span>{{ $t("dashboard.trafficChart") }}</span>
<el-radio-group v-model="range" size="small" @change="loadDashboard"> <el-radio-group v-model="range" size="small" @change="loadDashboard">
<el-radio-button label="1h">1h</el-radio-button> <el-radio-button value="1h">1h</el-radio-button>
<el-radio-button label="24h">24h</el-radio-button> <el-radio-button value="24h">24h</el-radio-button>
<el-radio-button label="7d">7d</el-radio-button> <el-radio-button value="7d">7d</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
</template> </template>
@@ -183,7 +224,14 @@ const summary = ref<DashboardSummaryVo>({
diskTotalBytes: 0, diskTotalBytes: 0,
diskPercent: 0, diskPercent: 0,
}, },
hysteria: { version: "-", running: false, apiReachable: false }, // До первого ответа сервера о службе не известно ничего: `inactive` здесь
// было бы утверждением, которого никто не проверял.
hysteria: {
version: "-",
serviceState: "unknown",
running: false,
apiReachable: false,
},
peers: { peers: {
total: 0, total: 0,
enabled: 0, enabled: 0,
@@ -231,6 +279,23 @@ const isStale = computed(() => {
return Date.now() - lastSuccessAt.value > staleThresholdMs; 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 () => { const loadDashboard = async () => {
if (loading.value) { if (loading.value) {
return; 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>
File diff suppressed because it is too large Load Diff
+14 -99
View File
@@ -1,109 +1,24 @@
<template> <template>
<div class="app-container"> <log-viewer
<div class="page-toolbar"> :fetch-logs="logHysteria2Api"
<el-form ref="queryFormRef" :model="queryParams" :inline="true"> :export-option="1"
<el-form-item :label="$t('log.numLine')" prop="numLine"> export-file-name="hysteria2.log"
<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>
</template> </template>
<script lang="ts"> <script lang="ts">
export default { export default {
name: "index", name: "LogHysteria",
}; };
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { LogDto, LogHysteria2Vo } from "@/api/log/types"; /**
import { exportLogApi, logHysteria2Api } from "@/api/log"; * Журнал Hysteria.
import { useI18n } from "vue-i18n"; *
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer — см.
const { t } = useI18n(); * страницу системного журнала.
*/
const state = reactive({ import LogViewer from "@/components/LogViewer/index.vue";
loading: true, import { logHysteria2Api } from "@/api/log";
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();
});
</script> </script>
+15 -99
View File
@@ -1,109 +1,25 @@
<template> <template>
<div class="app-container"> <log-viewer
<div class="page-toolbar"> :fetch-logs="logSystemApi"
<el-form ref="queryFormRef" :model="queryParams" :inline="true"> :export-option="0"
<el-form-item :label="$t('log.numLine')" prop="numLine"> export-file-name="hy2xs-admin.log"
<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>
</template> </template>
<script lang="ts"> <script lang="ts">
export default { export default {
name: "index", name: "LogSystem",
}; };
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { LogDto, LogSystemVo } from "@/api/log/types"; /**
import { exportLogApi, logSystemApi } from "@/api/log"; * Журнал админки.
import { useI18n } from "vue-i18n"; *
* Разметка, ширины колонок и обработка выгрузки живут в LogViewer: страница
const { t } = useI18n(); * системного журнала и страница журнала Hysteria были побайтово одинаковы и
* несли одни и те же дефекты в двух экземплярах.
const state = reactive({ */
loading: true, import LogViewer from "@/components/LogViewer/index.vue";
total: 0, import { logSystemApi } from "@/api/log";
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();
});
</script> </script>
+89 -7
View File
@@ -83,6 +83,16 @@ import { useAdminStore } from "@/store/modules/admin";
// Зависимость API // Зависимость API
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router"; import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
import { AdminLoginDto } from "@/api/admin/types"; 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 adminStore = useAdminStore();
const route = useRoute(); const route = useRoute();
@@ -114,6 +124,21 @@ const loginForm = ref<AdminLoginDto>({
pass: "", pass: "",
}); });
/**
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
*
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
* символов, из-за чего форма входа отказывалась отправлять пароль, который
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
*
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
* отказывал. См. isValidAdminPassword.
*/
const loginRules = { const loginRules = {
username: [ username: [
{ {
@@ -122,8 +147,12 @@ const loginRules = {
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
{ {
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/, pattern: ADMIN_USERNAME_PATTERN,
message: t("login.usernameFormatIncorrect"), message: t("credentials.usernameFormat", {
min: ADMIN_USERNAME_MIN_LENGTH,
max: ADMIN_USERNAME_MAX_LENGTH,
charset: ADMIN_USERNAME_CHARSET,
}),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
], ],
@@ -133,11 +162,13 @@ const loginRules = {
message: t("common.required"), message: t("common.required"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
{ adminPasswordFormRule(
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/, t("credentials.passwordFormat", {
message: t("login.passwordFormatIncorrect"), min: ADMIN_PASSWORD_MIN_LENGTH,
trigger: ["change", "blur"], 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 { .el-form-item {
background: var(--subMenuBg); background: var(--subMenuBg);
border: 1px solid rgb(255 255 255 / 12%); border: 1px solid rgb(255 255 255 / 12%);
border-radius: 5px; 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 { .el-input {
+504 -80
View File
@@ -30,7 +30,17 @@
> >
<el-button>{{ $t("common.import") }}</el-button> <el-button>{{ $t("common.import") }}</el-button>
</el-upload> </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">{{ <el-button class="ml-2" @click="handleExport">{{
$t("peer.exportSettings") $t("peer.exportSettings")
}}</el-button> }}</el-button>
@@ -44,6 +54,20 @@
{{ $t("peer.exportBackup") }} {{ $t("peer.exportBackup") }}
</el-button> </el-button>
</div> </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 v-loading="loading" :data="records">
<el-table-column :label="$t('peer.name')" min-width="220"> <el-table-column :label="$t('peer.name')" min-width="220">
<template #default="scope"> <template #default="scope">
@@ -63,6 +87,14 @@
}}</el-tag }}</el-tag
> >
<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" size="small"
class="ml-1" class="ml-1"
:type="scope.row.online ? 'success' : 'info'" :type="scope.row.online ? 'success' : 'info'"
@@ -92,8 +124,12 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="$t('peer.devices')" min-width="120"> <el-table-column :label="$t('peer.devices')" min-width="120">
<!--
Число подключённых устройств известно только из Traffic Stats API.
Пока он молчит, «0» было бы утверждением, а не данными.
-->
<template #default="scope" <template #default="scope"
>{{ scope.row.onlineDevices }} / >{{ onlineUnknown ? "?" : scope.row.onlineDevices }} /
{{ scope.row.maxDevices }}</template {{ scope.row.maxDevices }}</template
> >
</el-table-column> </el-table-column>
@@ -132,35 +168,53 @@
@click="copyUri(peerRow(scope.row))" @click="copyUri(peerRow(scope.row))"
>{{ $t("peer.copyUri") }}</el-button >{{ $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> <el-button text circle class="peer-more-btn" @click.stop>
<i-ep-more-filled /> <i-ep-more-filled />
</el-button> </el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="showQr(peerRow(scope.row))">{{ <el-dropdown-item command="qr">{{
$t("peer.clientQr") $t("peer.clientQr")
}}</el-dropdown-item> }}</el-dropdown-item>
<el-dropdown-item <el-dropdown-item command="edit">{{
@click="handleUpdate(peerRow(scope.row))" $t("common.edit")
>{{ $t("common.edit") }}</el-dropdown-item }}</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="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") $t("peer.kick")
}}</el-dropdown-item> }}</el-dropdown-item>
<el-dropdown-item <el-dropdown-item command="release-kick">{{
@click="handleReleaseKick(peerRow(scope.row))" $t("peer.releaseKick")
>{{ $t("peer.releaseKick") }}</el-dropdown-item }}</el-dropdown-item>
> <el-dropdown-item divided command="delete">{{
<el-dropdown-item $t("common.delete")
divided }}</el-dropdown-item>
@click="handleDelete(peerRow(scope.row))"
>{{ $t("common.delete") }}</el-dropdown-item
>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -184,21 +238,37 @@
:rules="rules" :rules="rules"
label-width="140px" 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 <el-input
v-model="dataForm.name" v-model="dataForm.name"
:placeholder="$t('peer.form.namePlaceholder')" :placeholder="$t('peer.form.namePlaceholder')"
/> />
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div> <div class="form-hint">
{{
$t("peer.form.nameHint", {
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
punctuation: PEER_NAME_PUNCTUATION,
})
}}
</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('peer.remark')"> <el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
<el-input <el-input
v-model="dataForm.remark" v-model="dataForm.remark"
:placeholder="$t('peer.form.remarkPlaceholder')" :placeholder="$t('peer.form.remarkPlaceholder')"
/> />
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div> <div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
</el-form-item> </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 <el-input
v-model="dataForm.secret" v-model="dataForm.secret"
show-password show-password
@@ -206,20 +276,29 @@
/> />
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div> <div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
</el-form-item> </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" /> <el-input-number v-model="dataForm.quotaBytes" :min="-1" />
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div> <div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('peer.expireTime')" <el-form-item
:label="$t('peer.expireTime')"
:error="serverErrors.expiresAt"
><el-date-picker ><el-date-picker
v-model="dataForm.expiresAt" v-model="dataForm.expiresAt"
type="datetime" type="datetime"
value-format="x" value-format="x"
/></el-form-item> /></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-input-number v-model="dataForm.maxDevices" :min="1"
/></el-form-item> /></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-switch v-model="disabledBool"
/></el-form-item> /></el-form-item>
</el-form> </el-form>
@@ -293,7 +372,8 @@
{{ formatBytes(overview.data.uploadBytes) }} {{ formatBytes(overview.data.uploadBytes) }}
</p> </p>
<p> <p>
<b>{{ $t("peer.devices") }}:</b> {{ overview.data.onlineDevices }} / <b>{{ $t("peer.devices") }}:</b>
{{ onlineUnknown ? "?" : overview.data.onlineDevices }} /
{{ overview.data.maxDevices }} {{ overview.data.maxDevices }}
</p> </p>
<p> <p>
@@ -342,7 +422,7 @@
</template> </template>
<script setup lang="ts"> <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 QrcodeVue from "qrcode.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
@@ -363,12 +443,48 @@ import {
exportPeerApi, exportPeerApi,
} from "@/api/peer"; } from "@/api/peer";
import { import {
PeerOnlineState,
PeerPageDto, PeerPageDto,
PeerSaveDto, PeerSaveDto,
PeerUpdateDto, PeerUpdateDto,
PeerVo, PeerVo,
} from "@/api/peer/types"; } from "@/api/peer/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus"; 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";
import {
PEER_NAME_CHARSET,
PEER_NAME_MAX_LENGTH,
PEER_NAME_MIN_LENGTH,
PEER_NAME_PATTERN,
PEER_NAME_PUNCTUATION,
} from "@/constants/peer";
/**
* Ограничение ширины всплывающих подсказок.
*
* Объявлено значением, а не литералом в шаблоне: подсказок в панели больше
* одной, и «широкая» подсказка — это свойство панели, а не отдельной кнопки.
*/
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 +512,14 @@ const { t } = useI18n();
const loading = ref(false); const loading = ref(false);
const total = ref(0); const total = ref(0);
const records = ref<PeerVo[]>([]); const records = ref<PeerVo[]>([]);
/**
* Известна ли картина подключений.
*
* До первого ответа считается известной: пустая таблица и без того ничего не
* утверждает, а предупреждение на пустом экране только сбивало бы.
*/
const onlineState = ref<PeerOnlineState>("ok");
const onlineUnknown = computed(() => onlineState.value === "unavailable");
const qrDialog = ref(false); const qrDialog = ref(false);
const qrUrl = ref(""); const qrUrl = ref("");
const importFileList = ref<UploadFile[]>([]); const importFileList = ref<UploadFile[]>([]);
@@ -430,6 +554,52 @@ const disabledBool = computed({
set: (v: boolean) => (dataForm.disabled = v ? 1 : 0), 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];
}
}
}
);
const SECRET_MIN_LENGTH = 6;
const SECRET_MAX_LENGTH = 128;
const rules = { const rules = {
name: [ name: [
{ {
@@ -437,6 +607,57 @@ const rules = {
message: t("common.required"), message: t("common.required"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
{
pattern: PEER_NAME_PATTERN,
message: t("error.code.peer_name", {
field: t("error.field.name"),
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
charset: PEER_NAME_CHARSET,
}),
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 +696,17 @@ async function handleQuery() {
const { data } = await pagePeerApi(queryParams); const { data } = await pagePeerApi(queryParams);
records.value = data.records; records.value = data.records;
total.value = data.total; total.value = data.total;
// Признак приходит один на страницу: недоступность Traffic Stats API —
// свойство ответа целиком. При `unavailable` поле `online` в строках не
// означает ничего, и показывать его как «офлайн» нельзя.
onlineState.value = data.onlineState;
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
function handleAdd() { function handleAdd() {
clearServerErrors();
Object.assign(dataForm, { Object.assign(dataForm, {
id: undefined, id: undefined,
name: "", name: "",
@@ -497,6 +723,7 @@ function handleAdd() {
} }
async function handleUpdate(row: PeerVo) { async function handleUpdate(row: PeerVo) {
clearServerErrors();
const { data } = await getPeerApi({ id: row.id }); const { data } = await getPeerApi({ id: row.id });
Object.assign(dataForm, data, { secret: "" }); Object.assign(dataForm, data, { secret: "" });
dialog.title = t("common.update"); dialog.title = t("common.update");
@@ -505,69 +732,254 @@ async function handleUpdate(row: PeerVo) {
} }
async function submitForm() { async function submitForm() {
clearServerErrors();
if (formRef.value) { if (formRef.value) {
const ok = await formRef.value.validate().catch(() => false); const ok = await formRef.value.validate().catch(() => false);
if (!ok) return; if (!ok) return;
} }
if (dialog.editId > 0 && dataForm.secret) { // Отмена подтверждения — ответ оператора, а не ошибка: форма остаётся
await ElMessageBox.confirm( // открытой с введённым, и в консоль ничего не улетает.
t("peer.secretRotateConfirm"), if (
t("common.warning"), dialog.editId > 0 &&
{ type: "warning" } dataForm.secret &&
); !(await confirmAction(t("peer.secretRotateConfirm")))
) {
return;
} }
if (dialog.editId > 0) {
const payload: PeerUpdateDto = { try {
id: dialog.editId, if (dialog.editId > 0) {
name: dataForm.name, const payload: PeerUpdateDto = {
secret: dataForm.secret || undefined, id: dialog.editId,
quotaBytes: dataForm.quotaBytes, name: dataForm.name,
expiresAt: dataForm.expiresAt, // Пустой секрет при изменении означает «не менять», и сервер читает
maxDevices: dataForm.maxDevices, // его именно так. Отправлять undefined больше не требуется, но и вреда
disabled: dataForm.disabled, // в этом нет: оба состояния для него теперь одинаковы.
remark: dataForm.remark, secret: dataForm.secret || undefined,
}; quotaBytes: dataForm.quotaBytes,
await updatePeerApi(payload); expiresAt: dataForm.expiresAt,
} else { maxDevices: dataForm.maxDevices,
await savePeerApi(dataForm); disabled: dataForm.disabled,
remark: dataForm.remark,
};
await updatePeerApi(payload);
} else {
// Секрет отправляется как есть, включая пустую строку: автогенерация —
// обязанность сервера, а не подстановка значения здесь.
await savePeerApi(dataForm);
}
} catch (error) {
applyServerErrors(error);
return;
} }
dialog.visible = false; dialog.visible = false;
await handleQuery(); 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) { async function handleDelete(row: PeerVo) {
await ElMessageBox.confirm( if (
t("common.deleteConfirm", { username: row.name }), !(await confirmAction(t("common.deleteConfirm", { username: row.name })))
t("common.warning"), ) {
{ type: "warning" } return;
); }
await deletePeerApi({ id: row.id }); try {
await deletePeerApi({ id: row.id });
ElMessage.success(t("common.success"));
} catch (error) {
reportPeerActionError(error);
}
await handleQuery(); await handleQuery();
} }
async function handleResetTraffic(row: PeerVo) { async function handleResetTraffic(row: PeerVo) {
await ElMessageBox.confirm( if (!(await confirmAction(t("common.resetTrafficConfirm")))) {
t("common.resetTrafficConfirm"), return;
t("common.warning"), }
{ type: "warning" } try {
); await resetPeerTrafficApi({ id: row.id });
await resetPeerTrafficApi({ id: row.id }); ElMessage.success(t("common.success"));
} catch (error) {
reportPeerActionError(error);
}
await handleQuery(); 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) { 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(); await handleQuery();
} }
async function handleReleaseKick(row: PeerVo) { 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(); await handleQuery();
} }
async function copyUri(row: PeerVo) { async function copyUri(row: PeerVo) {
// Копирование без подтверждения выглядит как ничего не сделавшая кнопка:
// буфер обмена оператору не виден.
const { data } = await getPeerClientConfigApi(row.id); const { data } = await getPeerClientConfigApi(row.id);
copy(data.url); copy(data.url);
ElMessage.success(t("common.copySuccess"));
} }
async function showQr(row: PeerVo) { async function showQr(row: PeerVo) {
@@ -576,13 +988,30 @@ async function showQr(row: PeerVo) {
qrDialog.value = true; qrDialog.value = true;
} }
/**
* Импорт выгрузки пиров.
*
* Исход разбирается тем же обработчиком, что и действия строки: импорт умеет
* завершиться ЧАСТИЧНО — партия применена целиком, но завершить старые сессии
* обновлённых пиров не удалось.
*
* Раньше здесь не было ни try, ни catch: отказ уходил необработанным
* отклонением промиса, а `handleQuery()` до выполнения не доходил — список
* оставался с прежними данными, хотя база уже изменилась. Убирается файл из
* очереди и обновляется список ПРИ ЛЮБОМ исходе по той же причине.
*/
async function handleImport(params: UploadRequestOptions) { async function handleImport(params: UploadRequestOptions) {
if (importFileList.value.length <= 0) { if (importFileList.value.length <= 0) {
return; return;
} }
const formData = new FormData(); const formData = new FormData();
formData.append("file", params.file); formData.append("file", params.file);
await importPeerApi(formData); try {
await importPeerApi(formData);
ElMessage.success(t("common.success"));
} catch (error) {
reportPeerActionError(error);
}
importFileList.value = []; importFileList.value = [];
await handleQuery(); await handleQuery();
} }
@@ -602,20 +1031,12 @@ function beforeImport(file: UploadRawFile) {
async function downloadExport(includeSecrets: boolean) { async function downloadExport(includeSecrets: boolean) {
try { try {
const response = await exportPeerApi(includeSecrets); const response = await exportPeerApi(includeSecrets);
const blob = new Blob([response.data], { saveResponseAsFile(response, "peers-export.json");
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);
ElMessage.success(t("common.downloadSuccess")); ElMessage.success(t("common.downloadSuccess"));
} catch { } catch {
ElMessage.error(t("common.invalid")); // Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
// общий разбор конверта: у Blob нет полей code и errors.
ElMessage.error(t("common.systemError"));
} }
} }
@@ -628,7 +1049,7 @@ async function handleExport() {
// Подтверждение здесь обязательно — это единственное место в панели, где // Подтверждение здесь обязательно — это единственное место в панели, где
// оператор осознанно выгружает наружу работающие учётные данные доступа. // оператор осознанно выгружает наружу работающие учётные данные доступа.
async function handleExportBackup() { async function handleExportBackup() {
await ElMessageBox.confirm( const confirmed = await confirmAction(
t("peer.exportBackupConfirm"), t("peer.exportBackupConfirm"),
t("common.securityRisk"), t("common.securityRisk"),
{ {
@@ -638,6 +1059,9 @@ async function handleExportBackup() {
confirmButtonClass: "el-button--danger", confirmButtonClass: "el-button--danger",
} }
); );
if (!confirmed) {
return;
}
await downloadExport(true); await downloadExport(true);
} }
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import { createI18n } from "vue-i18n";
import {
PEER_NAME_MAX_LENGTH,
PEER_NAME_MIN_LENGTH,
PEER_NAME_PUNCTUATION,
} from "../src/constants/peer";
import en from "../src/lang/package/en";
import ru from "../src/lang/package/ru";
type Dictionary = Record<string, unknown>;
function leafMessages(
value: unknown,
prefix = ""
): Array<[key: string, message: string]> {
if (typeof value === "string") {
return [[prefix, value]];
}
if (!value || typeof value !== "object") {
return [];
}
return Object.entries(value as Dictionary).flatMap(([key, child]) =>
leafMessages(child, prefix ? `${prefix}.${key}` : key)
);
}
function interpolationValues(message: string): Record<string, string | number> {
const values: Record<string, string | number> = {
count: 2,
n: 2,
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
punctuation: PEER_NAME_PUNCTUATION,
};
for (const match of message.matchAll(/\{([A-Za-z_][A-Za-z0-9_$-]*)\}/g)) {
values[match[1]] ??= match[1];
}
return values;
}
for (const [locale, dictionary] of Object.entries({ ru, en }) as Array<
["ru" | "en", Dictionary]
>) {
describe(`runtime-компиляция локализации ${locale}`, () => {
test("каждое строковое сообщение компилируется без diagnostics", () => {
const compilerDiagnostics: string[] = [];
const thrown: string[] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
compilerDiagnostics.push(args.map(String).join(" "));
};
try {
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: false,
missingWarn: false,
fallbackWarn: false,
messages: { [locale]: dictionary },
});
for (const [key, message] of leafMessages(dictionary)) {
try {
const translated = i18n.global.t(key, interpolationValues(message));
if (typeof translated !== "string") {
thrown.push(`${key}: результат не является строкой`);
}
} catch (error) {
thrown.push(
`${key}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
} finally {
console.error = originalConsoleError;
}
expect(thrown).toEqual([]);
expect(compilerDiagnostics).toEqual([]);
});
test("набор знаков имени пира проходит интерполяцию как данные", () => {
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: false,
messages: { [locale]: dictionary },
});
const template = (dictionary as any).peer.form.nameHint as string;
expect(
i18n.global.t("peer.form.nameHint", {
min: PEER_NAME_MIN_LENGTH,
max: PEER_NAME_MAX_LENGTH,
punctuation: PEER_NAME_PUNCTUATION,
})
).toBe(
template
.replace("{min}", String(PEER_NAME_MIN_LENGTH))
.replace("{max}", String(PEER_NAME_MAX_LENGTH))
.replace("{punctuation}", PEER_NAME_PUNCTUATION)
);
});
});
}
+5 -2
View File
@@ -10,14 +10,17 @@ go 1.25.0
// //
// Директива `go` выше — это языковой baseline модуля, и она НЕ выбирает // Директива `go` выше — это языковой baseline модуля, и она НЕ выбирает
// компилятор: с ней одной локальный `go build` на 1.25 проходил успешно, хотя // компилятор: с ней одной локальный `go build` на 1.25 проходил успешно, хотя
// релизный бинарь собирается на 1.26.7 и наследует её stdlib. То есть // релизный бинарь собирается версией из versions.env и наследует её stdlib.
// Номер здесь не повторяется намеренно — он живёт строкой ниже и в
// GO_VERSION, а третья копия в прозе устаревала бы на каждом patch-релизе. То
// есть
// разработчик и сборка проверяли разный код, а расхождение не было видно ни в // разработчик и сборка проверяли разный код, а расхождение не было видно ни в
// одном из выводов. // одном из выводов.
// //
// Значение обязано совпадать с GO_VERSION из versions.env; это проверяет // Значение обязано совпадать с GO_VERSION из versions.env; это проверяет
// verify_go_toolchain_contract, а `tools/dev/doctor` показывает то же // verify_go_toolchain_contract, а `tools/dev/doctor` показывает то же
// расхождение локально, до сборки. // расхождение локально, до сборки.
toolchain go1.26.7 toolchain go1.26.8
require ( require (
github.com/didip/tollbooth v4.0.2+incompatible github.com/didip/tollbooth v4.0.2+incompatible
+3 -3
View File
@@ -12,18 +12,18 @@ func AdminHandler() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
claimsRaw, ok := c.Get("adminClaims") claimsRaw, ok := c.Get("adminClaims")
if !ok { if !ok {
vo.Fail(constant.UnauthorizedError, c) vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
c.Abort() c.Abort()
return return
} }
claims, castOK := claimsRaw.(bo.AccountBo) claims, castOK := claimsRaw.(bo.AccountBo)
if !castOK { if !castOK {
vo.Fail(constant.IllegalTokenError, c) vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
c.Abort() c.Abort()
return return
} }
if !util.ArrContain(claims.Roles, "admin") { if !util.ArrContain(claims.Roles, "admin") {
vo.Fail(constant.ForbiddenError, c) vo.FailForbidden(constant.ForbiddenError, c)
c.Abort() c.Abort()
return return
} }
+47 -6
View File
@@ -1,41 +1,62 @@
package middleware package middleware
import ( import (
"errors"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/service" "hy2xs-admin/service"
"strings"
) )
// Отказ аутентификации несёт КОД состояния сессии.
//
// Раньше все ветки здесь звали vo.Fail с человеческой строкой, а код ответа
// выводился в vo сравнением этой строки с тремя известными литералами. Под
// условия подходил только `unauthorized`; `token expired` и `authentication
// failed` уезжали к панели как обычная системная ошибка с кодом 50000.
//
// Следствие было видимым для оператора: истёкшая сессия на открытой странице
// давала голый тост «token expired», ветка «войдите заново» не срабатывала
// никогда, а перебросить на форму входа мог только переход по маршруту,
// которому потребовался бы getAdminInfo. Ключ локализации `common.sessionExpired`
// при этом существовал и был мёртвым.
func JWTHandler() gin.HandlerFunc { func JWTHandler() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.Request.Header.Get("Authorization") authHeader := c.Request.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
vo.Fail(constant.UnauthorizedError, c) vo.FailUnauthorized(constant.ErrCodeUnauthorized, constant.UnauthorizedError, c)
c.Abort() c.Abort()
return return
} }
parts := strings.SplitN(authHeader, " ", 2) parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") { if !(len(parts) == 2 && parts[0] == "Bearer") {
vo.Fail(constant.IllegalTokenError, c) vo.FailUnauthorized(constant.ErrCodeTokenInvalid, constant.IllegalTokenError, c)
c.Abort() c.Abort()
return return
} }
myClaims, err := service.ParseToken(parts[1]) myClaims, err := service.ParseToken(parts[1])
if err != nil { if err != nil {
vo.Fail(err.Error(), c) vo.FailUnauthorized(tokenErrorCode(err), err.Error(), c)
c.Abort() c.Abort()
return return
} }
admin, err := service.GetAdminForTokenValidation(myClaims.Admin.Id) admin, err := service.GetAdminForTokenValidation(myClaims.Admin.Id)
if err != nil { if err != nil {
// Это уже не состояние сессии, а отказ чтения учётной записи:
// сворачивать его в «войдите заново» значило бы отправлять
// оператора на форму входа при недоступной базе.
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
c.Abort() c.Abort()
return return
} }
if admin.Status != nil && *admin.Status != 1 { 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() c.Abort()
return return
} }
@@ -44,7 +65,9 @@ func JWTHandler() gin.HandlerFunc {
tokenVersion = *admin.TokenVersion tokenVersion = *admin.TokenVersion
} }
if myClaims.Admin.TokenVersion != tokenVersion { if myClaims.Admin.TokenVersion != tokenVersion {
vo.Fail(constant.IllegalTokenError, c) // Версия токена сменилась: пароль изменён или доступ отозван.
// Для оператора это неотличимо от истёкшей сессии — вход заново.
vo.FailUnauthorized(constant.ErrCodeSessionExpired, constant.IllegalTokenError, c)
c.Abort() c.Abort()
return return
} }
@@ -52,3 +75,21 @@ func JWTHandler() gin.HandlerFunc {
c.Next() c.Next()
} }
} }
// tokenErrorCode различает истёкший токен и недействительный.
//
// Вопрос задаётся ЗНАЧЕНИЮ ошибки, а не её тексту: service.ParseToken
// возвращает объявленные значения, поэтому правка формулировки сообщения не
// может молча превратить истёкшую сессию в неизвестную ошибку.
//
// Отказ прочитать ключ подписи (недоступная база) сюда тоже приходит, и это
// НЕ состояние сессии. Отдельного кода он не получает намеренно: снаружи
// панели такой отказ неотличим от недействительного токена, и предлагать
// оператору войти заново — единственное осмысленное действие, которое ему
// доступно.
func tokenErrorCode(err error) string {
if errors.Is(err, service.ErrTokenExpired) {
return constant.ErrCodeSessionExpired
}
return constant.ErrCodeTokenInvalid
}
+28 -3
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"fmt"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -79,13 +80,37 @@ func LogHandler() gin.HandlerFunc {
} }
entry := logrus.WithFields(fields) entry := logrus.WithFields(fields)
message := RequestLogMessage(c.Request.Method, c.Request.URL.Path, statusCode, latencyTime)
if statusCode >= 500 { if statusCode >= 500 {
entry.Error() entry.Error(message)
} else if statusCode >= 400 { } else if statusCode >= 400 {
entry.Warn() entry.Warn(message)
} else { } 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())
}
+66
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"bytes" "bytes"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "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) { func TestLogHandlerRedactsQueryValuesOnOperatorRoutes(t *testing.T) {
+90
View File
@@ -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)
}
}
+79
View File
@@ -12,3 +12,82 @@ const (
WrongPassword string = "wrong password" WrongPassword string = "wrong password"
ConfigNotExist string = "config not exist" 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"
)
+33 -2
View File
@@ -1,6 +1,37 @@
package dto package dto
// Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ.
//
// Пока границы расходились, панель запирала оператора снаружи после операции,
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
// невозможно войти.
//
// Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом
// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает
// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических
// букв проходил проверку формы, доходил до util.HashPassword и возвращался
// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить
// нечем — правила длины в байтах у него нет.
//
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
type AdminChangePasswordDto struct { type AdminChangePasswordDto struct {
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"` OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"`
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"` 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)
} }
+52 -2
View File
@@ -1,6 +1,56 @@
package dto 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 { type LoginDto struct {
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` Username *string `json:"username" form:"username" validate:"required,credentialStr"`
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"` 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)
} }
+10
View File
@@ -7,6 +7,16 @@ type BaseDto struct {
EndTime *int64 `json:"endTime" form:"endTime" validate:"omitempty,gt=0"` // Время окончания 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 { type IdDto struct {
Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // Первичный ключ
} }
+5
View File
@@ -4,6 +4,11 @@ type LogDto struct {
NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"` NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"`
} }
// Normalize: «показать 0 строк» — это не запрос, а пропущенный параметр.
func (d *LogDto) Normalize() {
zeroToNil(&d.NumLine)
}
type LogExportDto struct { type LogExportDto struct {
Option *int `json:"option" form:"option" validate:"required,oneof=0 1"` Option *int `json:"option" form:"option" validate:"required,oneof=0 1"`
} }
+99
View File
@@ -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
}
}
+174
View File
@@ -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("пустые пароли формы смены остались заданными")
}
}
+61 -7
View File
@@ -1,31 +1,85 @@
package dto package dto
// Имя пира проверяется правилом `peerName`, которое несёт и набор символов, и
// длину.
//
// Раньше здесь стояло `min=1,max=32,validateStr`, где `validateStr` требовал
// 6-32 символа. Два правила на одном поле противоречили друг другу: имя из
// трёх символов проходило `min=1` и отказывалось на `validateStr`, а оператор
// видел «invalid» и подсказку «короткий идентификатор пира». Длина живёт
// внутри одного правила, чтобы такого расхождения больше не было.
type PeerPageDto struct { type PeerPageDto struct {
BaseDto 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"` 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 { 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"` Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"` QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"required,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"` ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"required,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"` MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"required,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"required,oneof=0 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 { type PeerUpdateDto struct {
IdDto // Id приходит из пути `/peers/:id`, а не из тела, поэтому здесь он
Name *string `json:"name" form:"name" validate:"omitempty,min=1,max=32,validateStr"` // НЕОБЯЗАТЕЛЕН.
//
// Раньше сюда встраивался 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"` Secret *string `json:"secret" form:"secret" validate:"omitempty,min=6,max=128"`
QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"` QuotaBytes *int64 `json:"quotaBytes" form:"quotaBytes" validate:"omitempty,min=-1"`
ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"` ExpiresAt *int64 `json:"expiresAt" form:"expiresAt" validate:"omitempty,min=0"`
MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"` MaxDevices *int64 `json:"maxDevices" form:"maxDevices" validate:"omitempty,min=1"`
Disabled *int64 `json:"disabled" form:"disabled" validate:"omitempty,oneof=0 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 { type PeerKickDto struct {
+15 -2
View File
@@ -31,9 +31,22 @@ type DashboardSystemVo struct {
DiskPercent float64 `json:"diskPercent"` DiskPercent float64 `json:"diskPercent"`
} }
// DashboardHysteriaVo — состояние Hysteria из ДВУХ независимых источников.
//
// `serviceState` отвечает systemd, `apiReachable` — фактическое обращение к
// Traffic Stats API. Раньше второе выводилось из первого: сборщик метрик
// получал от общего Hysteria2Online пустую карту без ошибки, когда systemctl
// говорил «неактивна», и выставлял `apiReachable = true`, ни разу не сходив в
// API. Дашборд показывал «служба остановлена» и «API доступен» одновременно.
//
// `running` сохранён для совместимости отображения и означает ровно
// `serviceState == active`. Решения на нём не строятся: «неизвестно» — это не
// «остановлена», и путать их продукту уже дорого обходилось.
type DashboardHysteriaVo struct { type DashboardHysteriaVo struct {
Version string `json:"version"` Version string `json:"version"`
Running bool `json:"running"` Running bool `json:"running"`
// ServiceState — active | inactive | unknown.
ServiceState string `json:"serviceState"`
ApiReachable bool `json:"apiReachable"` ApiReachable bool `json:"apiReachable"`
LastApiError string `json:"lastApiError,omitempty"` LastApiError string `json:"lastApiError,omitempty"`
} }
+132
View File
@@ -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"`
}
+8
View File
@@ -23,6 +23,14 @@ type LogSystemVo struct {
Time string `json:"time"` Time string `json:"time"`
} }
// LogHysteria2Vo — строка журнала Hysteria в том виде, в каком её показывает
// панель.
//
// Тип НЕ является формой upstream-записи и никогда не разбирается прямым
// json.Unmarshal: JSON-логгер Hysteria 2.12.2 пишет `time` числом
// (zapcore.EpochMillisTimeEncoder), и попытка сложить его в строковое поле
// роняла разбор целиком. Форма провода живёт в service/journal.go, здесь —
// только результат.
type LogHysteria2Vo struct { type LogHysteria2Vo struct {
Level string `json:"level"` Level string `json:"level"`
Msg string `json:"msg"` Msg string `json:"msg"`
+34 -2
View File
@@ -18,12 +18,44 @@ type PeerVo struct {
OnlineDevices int64 `json:"onlineDevices"` 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 { type PeerPageVo struct {
Records []PeerVo `json:"records"` Records []PeerVo `json:"records"`
Total int64 `json:"total"` Total int64 `json:"total"`
// OnlineState — ok | unavailable.
OnlineState string `json:"onlineState"`
} }
// PeerClientConfigVo — клиентская ссылка пира.
//
// Поля QrCode здесь больше нет. Оно было помечено deprecated и возило в
// браузер PNG, который панель не использует: QR рисуется во frontend из самой
// ссылки (qrcode.vue), и второй его экземпляр в ответе был лишним трафиком и
// вторым способом получить то же самое.
type PeerClientConfigVo struct { type PeerClientConfigVo struct {
Url string `json:"url"` Url string `json:"url"`
QrCode []byte `json:"qrCode,omitempty"` // deprecated: frontend renders SVG QR from Url
} }
+78 -16
View File
@@ -1,16 +1,31 @@
package vo package vo
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hy2xs-admin/model/constant" "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 { type result struct {
Code int `json:"code"` Code int `json:"code"`
Type string `json:"type"` Type string `json:"type"`
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data"` Errors []FieldError `json:"errors,omitempty"`
Data interface{} `json:"data"`
} }
const ( const (
@@ -26,21 +41,68 @@ func Success(data interface{}, c *gin.Context) {
}) })
} }
func Fail(message string, c *gin.Context) { // FailWith — единственное место, где формируется ответ об ошибке.
var code int //
if constant.UnauthorizedError == message { // Код передаётся аргументом. Раньше он ВЫВОДИЛСЯ здесь сравнением текста
code = constant.CodeUnauthorizedError // сообщения с тремя известными строками:
} else if constant.ForbiddenError == message { //
code = constant.CodeForbiddenError // if constant.UnauthorizedError == message { code = ... }
} else if constant.InvalidError == message { //
code = constant.CodeInvalidError // Это тот же антипаттерн, который запрещён панели, только на сервере: смысл
} else { // ответа определялся совпадением литерала. Следствие было не теоретическим —
code = constant.CodeSysError // истёкший токен возвращал `token expired`, под условия не подходил и уезжал
} // как обычная системная ошибка с кодом 50000. Панель показывала оператору
// голый тост и не понимала, что сессия кончилась: ветка входа заново не
// срабатывала никогда.
func FailWith(code int, message string, fieldErrors []FieldError, c *gin.Context) {
c.JSON(http.StatusOK, result{ c.JSON(http.StatusOK, result{
Code: code, Code: code,
Type: TypeError, Type: TypeError,
Message: message, Message: message,
Errors: fieldErrors,
Data: nil, 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)
}
+29 -3
View File
@@ -18,6 +18,32 @@ type Hysteria2Api struct {
apiPort int64 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 { func NewHysteria2Api(apiPort int64) *Hysteria2Api {
return &Hysteria2Api{ return &Hysteria2Api{
apiPort: apiPort, 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 { 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") return nil, errors.New("http connection error")
} }
body, err := io.ReadAll(resp.Body) 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 { 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 errors.New("http connection error")
} }
return nil return nil
@@ -111,7 +137,7 @@ func (h *Hysteria2Api) OnlineUsers(secret string) (map[string]int64, error) {
} }
}() }()
if err != nil || resp.StatusCode != http.StatusOK { 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") return nil, errors.New("http connection error")
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
+35 -1
View File
@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
@@ -25,13 +26,29 @@ func adminClaimsFromContext(c *gin.Context) (bo.AccountBo, bool) {
return claims, castOK return claims, castOK
} }
// ErrInvalidCredentials — логин или пароль не подошли.
//
// ОДНО значение на оба случая, и это не упрощение. «Такого администратора
// нет» и «пароль не тот» обязаны быть неразличимы снаружи: иначе форма входа
// превращается в способ проверять существование имён администраторов, а
// панель слушает только localhost именно потому, что вход — самая ценная
// дверь продукта.
//
// Отказ хранилища при этом сюда НЕ сворачивается: слой данных уже умеет
// отличать «записи нет» от «база не ответила» (dao.IsNotFound), и недоступная
// SQLite обязана выглядеть как системная ошибка, а не как неверный пароль.
var ErrInvalidCredentials = errors.New(constant.WrongPassword)
func Login(username string, plainPassword string) (string, bool, error) { func Login(username string, plainPassword string) (string, bool, error) {
admin, err := dao.GetAdminUser("username = ? and status = 1", username) admin, err := dao.GetAdminUser("username = ? and status = 1", username)
if err != nil { if err != nil {
if dao.IsNotFound(err) {
return "", false, ErrInvalidCredentials
}
return "", false, err return "", false, err
} }
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) { if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
return "", false, errors.New(constant.WrongPassword) return "", false, ErrInvalidCredentials
} }
tokenVersion := int64(1) tokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 { if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
@@ -42,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
if tokenErr != nil { if tokenErr != nil {
return "", false, tokenErr 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, который ставит установка. // флаг force_password_change, который ставит установка.
+80 -1
View File
@@ -117,21 +117,79 @@ func GetHysteria2ApiPort() (int64, error) {
return apiPort, nil 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) { func parseTrafficStatsPort(listen string) (int64, error) {
trimmed := strings.TrimSpace(listen) trimmed := strings.TrimSpace(listen)
if trimmed == "" { if trimmed == "" {
return 0, errors.New("empty listen") return 0, errors.New("empty listen")
} }
// Пустой хост подставляется только затем, чтобы SplitHostPort принял
// строку: сам по себе `:36712` означает все интерфейсы, и loopback среди
// них, поэтому подстановка не меняет ответа на вопрос о достижимости.
hostPort := trimmed hostPort := trimmed
if strings.HasPrefix(trimmed, ":") { if strings.HasPrefix(trimmed, ":") {
hostPort = "127.0.0.1" + trimmed hostPort = "127.0.0.1" + trimmed
} }
_, portStr, err := net.SplitHostPort(hostPort) host, portStr, err := net.SplitHostPort(hostPort)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if err := assertTrafficStatsHostReachable(host); err != nil {
return 0, err
}
port, err := strconv.ParseInt(portStr, 10, 64) port, err := strconv.ParseInt(portStr, 10, 64)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -141,3 +199,24 @@ func parseTrafficStatsPort(listen string) (int64, error) {
} }
return port, nil 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),
)
}
+162
View File
@@ -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()
}
+269 -84
View File
@@ -1,78 +1,168 @@
package service package service
import ( import (
"fmt"
"sort"
"sync"
"time"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/proxy" "hy2xs-admin/proxy"
"hy2xs-admin/util" "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() { func CronHandleAccount() {
go func() { // Пропуск тика при уже идущем цикле — не отказ: следующий тик через 30
if !Hysteria2IsRunning() { // секунд, а очередь из накопившихся циклов ничего бы не дала.
return if !accountJobMutex.TryLock() {
} return
}
defer accountJobMutex.Unlock()
apiPort, err := GetHysteria2ApiPort() apiPort, err := GetHysteria2ApiPort()
if err != nil { if err != nil {
return logrus.WithError(err).Error("cron: не удалось определить порт Traffic Stats API; цикл учёта пропущен")
} return
}
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) // Секрет берётся общей функцией, которая отличает «ключа нет» от пустого
if err != nil { // значения. Раньше здесь стояло `*trafficSecretConfig.Value` без единой
return // проверки: строка в таблице `config` без значения роняла бы процесс
} // паникой на разыменовании nil — причём внутри отсоединённой горутины, где
// её некому перехватить, то есть падал бы весь сервис вместе с
// обработчиком machine-auth.
secret, err := hysteria2TrafficSecret()
if err != nil {
logrus.WithError(err).Error("cron: секрет Traffic Stats API недоступен; цикл учёта пропущен")
return
}
// Сохранение данных трафика // Порядок обязателен: enforcement принимает решение по счётчикам, поэтому
go saveAccountTraffic(apiPort, *trafficSecretConfig.Value) // счётчики должны быть уже обновлены.
if err := saveAccountTraffic(apiPort, secret); err != nil {
logrus.WithError(err).Error("cron: сбор трафика завершился с ошибкой")
}
// Принудительное отключение if err := enforcePeerAccess(apiPort, secret); err != nil {
go kickAccount(apiPort, *trafficSecretConfig.Value) logrus.WithError(err).Error("cron: принудительное отключение завершилось с ошибкой")
}() }
} }
// CronResetTraffic обнуляет счётчики трафика всех пиров по расписанию.
func CronResetTraffic() { func CronResetTraffic() {
peers, err := dao.ListPeer("1=1") peers, err := dao.ListPeer("1=1")
if err != nil { if err != nil {
logrus.WithError(err).Error("cron: не удалось прочитать пиров для сброса трафика")
return return
} }
var ids []int64 ids := make([]int64, 0, len(peers))
for _, item := range peers { for _, item := range peers {
// Строка без идентификатора — повреждённые данные. Раньше здесь
// стояло `*item.Id` без проверки, то есть такая строка роняла джобу
// паникой, а вместе с ней и процесс.
if item.Id == nil {
logrus.Error("cron: строка пира без идентификатора пропущена при сбросе трафика")
continue
}
ids = append(ids, *item.Id) ids = append(ids, *item.Id)
} }
idsList := util.SplitArr(ids, 100) if len(ids) == 0 {
for _, item := range idsList { return
if err := dao.UpdatePeer(item, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}); err != nil { }
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 continue
} }
} }
} }
func saveAccountTraffic(apiPort int64, trafficStatsSecret string) { // saveAccountTraffic переносит накопленный Hysteria трафик в базу.
if !trafficMutex.TryLock() { //
return // Чтение ДЕСТРУКТИВНОЕ: `?clear=1` обнуляет счётчики Hysteria сразу после
} // того, как ответ отправлен (официальный контракт Traffic Stats API). Значит
defer trafficMutex.Unlock() // каждая дельта существует ровно в одном экземпляре, и потерянная здесь
// потеряна навсегда.
//
// Полностью закрыть это окно можно только сменой модели учёта — недеструктивным
// `GET /traffic` с долговременными checkpoint'ами верхних счётчиков и
// вычислением дельты на стороне админки. Это отдельная подсистема с обработкой
// перезапуска и сброса счётчиков Hysteria, и в текущем проходе она намеренно
// не вводится: квота здесь — операционная граница доступа, а не биллинговый
// учёт с финансово значимым каждым байтом.
//
// Чего это НЕ оправдывает — молчания. Раньше отказ записи внутри цикла делал
// `continue`, и дельта конкретного пира исчезала, не оставив следа в исходе
// джобы. Теперь каждая потеря считается и попадает в возвращаемую ошибку.
func saveAccountTraffic(apiPort int64, trafficStatsSecret string) error {
users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, trafficStatsSecret) users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, trafficStatsSecret)
if err != nil { if err != nil {
return return err
} }
if len(users) == 0 { if len(users) == 0 {
return return nil
} }
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
hourStart := nowMs - (nowMs % int64(time.Hour/time.Millisecond)) hourStart := nowMs - (nowMs % int64(time.Hour/time.Millisecond))
lost := 0
for key, traffic := range users { for key, traffic := range users {
rxBytes := traffic.Rx rxBytes := traffic.Rx
txBytes := traffic.Tx txBytes := traffic.Tx
@@ -82,9 +172,19 @@ func saveAccountTraffic(apiPort int64, trafficStatsSecret string) {
peer, peerErr := dao.GetPeer("auth_id = ?", key) peer, peerErr := dao.GetPeer("auth_id = ?", key)
if peerErr != nil { if peerErr != nil {
// Пир, которого админка не знает: удалён между сбором и записью
// либо создан в обход панели. Дельта уже обнулена в Hysteria и
// приписывать её некому.
logrus.WithError(peerErr).
WithField("authId", key).
Warn("cron: трафик получен для неизвестного пира и не записан")
lost++
continue continue
} }
if peer.Id == nil { if peer.Id == nil {
logrus.WithField("authId", key).
Error("cron: строка пира без идентификатора; трафик не записан")
lost++
continue continue
} }
@@ -100,66 +200,151 @@ func saveAccountTraffic(apiPort int64, trafficStatsSecret string) {
TxBytes: &txBytes, TxBytes: &txBytes,
SampledAt: &nowMs, SampledAt: &nowMs,
} }
if err = dao.SaveTrafficSample(sample); err != nil { if err := dao.SaveTrafficSample(sample); err != nil {
logrus.Errorf("save traffic_sample failed: %v", err) logrus.WithError(err).
continue 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), "download_bytes": gorm.Expr("download_bytes + ?", rxBytes),
"upload_bytes": gorm.Expr("upload_bytes + ?", txBytes), "upload_bytes": gorm.Expr("upload_bytes + ?", txBytes),
}); err != nil { }); err != nil {
logrus.Errorf("update peer traffic failed: %v", err) logrus.WithError(err).
WithField("peerId", *peer.Id).
Error("cron: счётчики пира не обновлены; дельта Hysteria уже обнулена и потеряна")
lost++
continue continue
} }
_ = dao.UpsertTrafficAggregateHourly(*peer.Id, hourStart, rxBytes, txBytes) _ = 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 lost > 0 {
if err != nil { return &trafficLossError{lost: lost}
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()
} }
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)
} }
+758
View File
@@ -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)
}
}
+17 -1
View File
@@ -12,6 +12,10 @@ func DashboardSummary() (vo.DashboardSummaryVo, error) {
summary := vo.DashboardSummaryVo{CollectedAt: nowMs} summary := vo.DashboardSummaryVo{CollectedAt: nowMs}
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"} summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"}
summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"} summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"}
// До первого снимка сборщика о службе не известно ничего. Нулевое значение
// `Running = false` прочиталось бы как «остановлена» — то самое смешение
// «неизвестно» и «выключено», от которого избавляет HysteriaServiceState.
summary.Hysteria.ServiceState = string(HysteriaServiceUnknown)
snapshot := DashboardSnapshot() snapshot := DashboardSnapshot()
if snapshot.CollectedAt > 0 { if snapshot.CollectedAt > 0 {
@@ -119,9 +123,21 @@ func DashboardSecurity() ([]vo.SecurityRiskVo, error) {
return summary.SecurityRisks, nil return summary.SecurityRisks, nil
} }
// DashboardSecurityRisks называет ровно то, что известно.
//
// «Служба остановлена» и «состояние службы неизвестно» — разные сообщения с
// разными действиями оператора. Пока состояние было булевым, второе выдавалось
// за первое: сломанный systemctl при живой Hysteria показывал критическую
// плашку «служба остановлена», и оператор шёл перезапускать работающий
// туннель.
func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo { func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo {
risks := make([]vo.SecurityRiskVo, 0) 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}) risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStopped", Severity: "critical", Dismissible: false})
} }
if !summary.Hysteria.ApiReachable { if !summary.Hysteria.ApiReachable {

Some files were not shown because too many files have changed in this diff Show More