9 Commits

Author SHA1 Message Date
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
155 changed files with 20324 additions and 5571 deletions
+681 -4
View File
@@ -46,6 +46,683 @@ Hardening-проход перед релизом `1.0.0`. Основная те
результат на достаточно большом входе, опаснее отсутствующей: отсутствующая результат на достаточно большом входе, опаснее отсутствующей: отсутствующая
ничего не обещает. ничего не обещает.
Девятый проход — работа оператора в панели, по итогам приёмки `v1.0.0-rc1` на
живом Debian 13. Общая тема прохода: обещания интерфейса, которые продукт не
выполнял, хотя умел. Подпись под полем предлагала оставить секрет пустым, и
сервер действительно умел его сгенерировать — до этой генерации не доходило
управление. Контракт `currentColor` был объявлен в двух местах — и не
действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла,
войдите заново» существовала — и была недостижима сразу по двум причинам.
Десятый проход — разбор кода после девятого, на коммите `c0a43ae9`, со сверкой
Hysteria-интеграции с официальной документацией Hysteria 2. Тема прохода:
операции, у которых есть только одна из двух необходимых половин. Отключение
пира записывало запрет и не разрывало его сессию. Ограничение устройств
проверялось, пока отвечал Traffic Stats API, и молча снималось, когда он не
отвечал. Журнал собирал структурные поля и записывал пустое сообщение. Разбор
задокументирован в
[docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md](docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md).
Одиннадцатый проход — второй разбор того же слоя, уже по состоянию после
десятого. Тема: границы между частями access-control. Десятый проход починил
одну операцию отзыва доступа и оставил остальные — удаление, импорт, смену
секрета, урезание квоты и срока, снижение лимита устройств — в прежнем
состоянии; правило доступа при этом продолжало существовать в двух
экземплярах, написанных разными SQL-условиями, которые расходились ровно на
границах. Проведены три границы: `состояние пира → решение о доступе`,
`сохранённое изменение → живая сессия`, `планировщик → полностью
принадлежащая ему работа`.
Двенадцатый проход — разбор состояния после одиннадцатого, снова со сверкой с
официальной документацией Hysteria 2. Тема: **вторая попытка**. Одиннадцатый
проход сделал правильным порядок «сначала запись, потом разрыв» и правильно
запретил откат при неудаче разрыва — но не дал системе способа прийти к
согласованному состоянию потом. Два состояния оставались навсегда: сессия
пира, которого импорт переподписал или удаление убрало, и превышение лимита
устройств после неудавшегося снижения. Вместе с ними закрыт второй TOCTOU в
лимите устройств — переупорядочивание снимков `/online`, которое учёт
разрешений сам по себе не ловил, а детектор гонок не мог показать в принципе.
Разбор задокументирован в
[docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md).
Тринадцатый проход — граница между HY2XS и Hysteria, со сверкой по исходникам
тега `app/v2.12.2`, а не только по документации. Предыдущие два прохода привели
в порядок внутреннюю логику отзыва доступа; здесь закрываются места, где эта
логика соприкасается с чужим компонентом и с оператором: идентичность сессий,
которая не менялась вместе с учётными данными и потому лишала цикл учёта
единственного признака отзыва; адрес Traffic Stats API, у которого было два
несовместимых контракта в одном продукте; формат журнала Hysteria, который
разбирался неверно на каждой строке; и панель, показывавшая как факт то, чего
никто не проверял. Разбор задокументирован в
[docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md](docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md).
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
существовало в двух экземплярах: SQL-условием внутри `Hysteria2Auth` и
другим SQL-условием внутри cron. Второе не было отрицанием первого, и
расхождение приходилось на границы — `quota = 0`, `usage = quota`,
`now = expiresAt`, `now = bannedUntil`: авторизация отказывала, cron сессию
не рвал. Условие cron требовало СТРОГОГО превышения квоты, а счётчики растут
порциями по ответу Traffic Stats API, поэтому точное равенство — обычный
исход очередного сбора. Пир с исчерпанной квотой не пускался заново, но его
живая сессия не разрывалась никогда.
Политика вынесена в одну функцию `peerAccessDenied`; авторизация ищет пира
только по `secret_digest`, cron применяет ту же функцию. `quota = -1`
единственный способ снять ограничение, `quota = 0` означает ноль байтов,
`usage = quota` означает исчерпанный лимит, `bannedUntil = now` означает
закончившуюся блокировку. Строка без решающего поля трактуется как
повреждённая и ведёт к отказу.
### Исправлено — операции, оставляющие живую сессию
- **Удаление пира не отзывало доступ и теряло `authId`.** `DeletePeer` состоял
из одного `dao.DeletePeer`: строка исчезала, живая QUIC-сессия оставалась, а
вместе со строкой исчезал `auth_id` — единственное, чем эту сессию можно было
бы завершить. Состояние становилось невосстановимым. Теперь: прочитать пира и
запомнить `authId` → записать `disabled=1``/kick` → удалить строку. При
неудаче разрыва строка остаётся отключённой, и оператор повторяет удаление.
- **Разрыв выполнялся только при `disabled=1`.** Мимо проходили смена секрета,
урезание квоты ниже израсходованного, перенос срока в прошлое и снижение
лимита устройств — каждая из них закрывает доступ, но сессию не трогала.
Правило асимметрично: ограничение применяется немедленно, послабление — нет.
При любом сочетании изменений уходит ровно один `/kick`.
- **Импорт не завершал сессии переписанных пиров.** Импорт переписывает
`auth_id`, секрет, квоту, срок и `disabled` целиком. Старые `authId`
собираются внутри транзакции — после commit их в базе уже нет, — а разрыв
идёт после commit: до него клиент успел бы переподключиться к ещё не
изменённому пиру.
- **Единственный вход к `/kick`.** Все операции идут через один
`reconcileLiveSessions`, а он — через `disconnectAuthIDs`, который принимает
готовые идентификаторы, дедуплицирует их, разбивает на части и не обращается
к базе вовсе. Пока обращений к `/kick` было два, они расходились: у cron не
было ни дедупликации, ни разбиения, зато был POST с пустым массивом каждые 30
секунд.
- **Формулировка частичного результата больше не называет операцию.** Через
`peer_disconnect_failed` отчитываются восемь операций; прежнее «новые
подключения пира запрещены» было верно ровно для отключения пира, а для
удалённого — бессмысленно. Контроллеры удаления и импорта переведены на
`failService`, панель разбирает исход импорта и обновляет список при любом
результате.
### Исправлено — цикл учёта
- **Джоба убегала из жизненного цикла планировщика.** `CronHandleAccount`
запускала горутину, которая запускала ещё две. Для планировщика джоба
заканчивалась почти мгновенно, поэтому `StopCron()` не ждал настоящей работы:
`releaseResource()` закрывал SQLite, а горутины продолжали в неё писать.
Параллельность обеих половин означала ещё и то, что принудительное отключение
читало счётчики до записи снятой дельты. Теперь джоба синхронна, под одним
мьютексом на весь цикл, и порядок строгий: сбор трафика, затем enforcement.
- **Три nil-разыменования роняли процесс целиком.** `*trafficSecretConfig.Value`,
`*item.AuthId` в принудительном отключении и `*item.Id` в сбросе трафика — все
внутри горутин, где их некому перехватить, то есть каждое означало падение
сервиса вместе с обработчиком machine-auth.
- **Гейт `Hysteria2IsRunning` удалён из cron.** `util.Exec` не отличает «служба
неактивна» от «спросить не удалось», поэтому сломанный `systemctl` при живой
Hysteria молча отключал и учёт трафика, и принудительное отключение — без
единой строки в журнале.
- **Потеря дельты трафика больше не молчит.** `GET /traffic?clear=1`
деструктивен: счётчики Hysteria обнуляются сразу после отправки ответа.
Прежний код на отказе записи делал `continue`, и дельта исчезала, не оставив
следа в исходе джобы. Полное решение требует смены модели учёта
(недеструктивное чтение плюс долговременные checkpoint'ы) и в `1.0.0`
намеренно не вводится: квота — операционный предел доступа, а не учёт с
финансово значимым каждым байтом.
### Исправлено — лимит устройств под нагрузкой
- **Параллельные подключения превышали `maxDevices`.** Между чтением `/online`
и ответом «allow» место ничем не удерживалось: при `online = max-1` два
одновременных запроса получали разрешение оба. Мьютекс вокруг `/online` этого
не чинит — ответив «allow», админка не создаёт подключение, и следующий
запрос продолжает видеть прежнее число. Появился process-local учёт выданных,
но ещё не проявившихся разрешений: решение принимается по сумме «подключено
плюс зарезервировано», рост `online` снимает соответствующее число
резерваций, протухшие снимаются по внутреннему TTL. Ни Redis, ни таблиц в
базе, ни распределённых блокировок: HY2XS — один процесс на одном сервере.
### Исправлено — сходимость отзыва доступа (двенадцатый проход)
- **Устаревший снимок `/online` возвращал уже занятое место.** Учёт выданных
разрешений закрыл сравнение двух одинаковых снимков, но сетевой запрос
по-прежнему выполнялся вне блокировки, поэтому снимки приходили в резервацию
в произвольном порядке. Более старый обгонял более новый и откатывал
`lastOnline` назад: `A` получил разрешение при `online = 0`; `C` обработал
`online = 1` первым и признал разрешение `A` проявившимся; пришедший следом
`B` со своим устаревшим `0` увидел место снова свободным. При
`maxDevices = 1` подключений становилось два. Детектор гонок здесь молчит
принципиально — вся работа с памятью защищена мьютексом, гонка логическая.
Последовательность «прочитать `/online` → занять место» выполняется под
замком **по `authId`**, а не одним на процесс: внутри неё идёт сетевой
запрос, и общий замок выстроил бы подключения всех пиров в очередь за одним
HTTP-обменом. Карта замков не растёт — запись живёт ровно столько, сколько
есть желающие её взять.
- **Живая сессия без строки в базе не завершалась никогда.** Цикл учёта читал
`dao.ListPeer("auth_id in ?")` и обходил найденные строки, поэтому `authId`,
которому в базе ничего не соответствует, молча выпадал. А именно он и
остаётся единственным следом сессии после неудавшегося второго шага: импорт
заменил `auth_id`, удаление убрало строку. Повторить операцию в этом
состоянии невозможно — повтор того же импорта читает из базы уже новое
значение и рвёт его, — а восстановить состояние переподключением нельзя:
авторизация нового значения не знает. Сессия жила неограниченно долго.
- **Снижение `maxDevices` после неудавшегося разрыва не имело второй попытки.**
Условие сравнивало `*peerDto.MaxDevices < *before.MaxDevices`, а форма при
правке отправляет все поля, поэтому повторное сохранение давало `1 < 1` и
разрыва не делало. Лимит устройств в политику доступа не входит и входить не
должен — это свойство сессий, а не пира, — поэтому механизма схождения у него
не было вовсе, в отличие от `disabled`, квоты, срока и блокировки.
- **Цикл учёта стал сверкой живых сессий.** Обход идёт по каждому `authId` из
`/online`: нет строки в базе → разрыв; `peerAccessDenied` → разрыв;
непригодный `maxDevices` → разрыв; устройств больше разрешённого → разрыв.
Отказ базы при этом не рвёт ничего: «пира нет» и «прочитать не удалось» —
разные ответы, и трактовка второго как первого отключила бы всех
подключённых пиров сразу при недоступной SQLite. Таблицы отложенных
операций, очереди retry и хранимого «списка того, что не удалось разорвать»
не появилось: список живых сессий уже есть, и это `/online`. Число устройств
сверено с официальным контрактом Traffic Stats API — `/online` возвращает
количество экземпляров клиента Hysteria, а не число proxy-потоков.
- **Go 1.26.7 → 1.26.8.** Patch-релиз от 2026-09-01 (fixes в cgo, компиляторе,
runtime, `debug/elf` и `os`). Stdlib целиком попадает в production-бинарь,
поэтому «на один патч позади» — свойство выпускаемого артефакта, а не среды
сборки. Обновлены `GO_VERSION` с контрольной суммой и `toolchain` в
`apps/go.mod`: расхождение между ними роняет сборку на
`verify_go_toolchain_contract`. Major не менялся — линия 1.26 поддерживается.
- **Учёт разрешений больше не растёт бесконечно.** Запись снималась только на
ветке отказа: после успешной выдачи она оставалась с непустым списком, а
когда разрешение протухало, снять её было уже некому — следующего обращения
к этому `authId` могло не быть никогда. В карте копились удалённые пиры и
старые идентификаторы, переписанные импортом. Уборка идёт по фактической
картине подключений в том же цикле учёта — единственном месте продукта, где
она известна целиком.
### Исправлено — отзыв учётных данных и граница с Hysteria (тринадцатый проход)
- **Смена секрета не отзывала доступ гарантированно.** Отзыв состоит из двух
шагов, и второй умеет не удаться — сходимость обязан обеспечить цикл учёта. Но
сверять ему было нечем: `auth_id` при смене секрета оставался прежним, поэтому
сессия, установленная по отозванным учётным данным, называлась тем же
значением, что и законная, пир в базе существовал, доступ был открыт,
устройств не больше разрешённого. Признака «установлена по недействительному
секрету» в системе не существовало.
Хуже того, у этого состояния есть путь **без единой неудачи**. Hysteria
дожидается ответа backend-auth и только после `ok = true` помечает соединение
аутентифицированным и сообщает о нём Traffic Stats API (проверено по
исходникам `app/v2.12.2`), поэтому `/kick`, прошедший успешно, пока
авторизация по старому секрету ещё выполнялась, этого соединения не видит.
Атомарной пары «решение авторизации + регистрация онлайна» upstream API не
даёт, и повторным чтением базы перед ответом окно не закрыть.
Теперь новое поколение учётных данных получает новый `auth_id`, а `/kick` идёт
по старому: пережившая сессия становится orphan и завершается очередным циклом
учёта — механизмом, который уже существует. Правило действует на обеих дверях
к смене секрета, включая импорт, где случай «прежний `auth_id` + новый секрет»
проходил мимо. Ротация происходит тогда и только тогда, когда меняется
`secret_digest`. Цена названа прямо: трафик доживающей сессии за эти секунды
не приписывается пиру и попадает в потери цикла.
- **Адрес Traffic Stats API имел два контракта.** Оркестратор принимал любой
IPv4 и честно подставлял его в `trafficStats.listen`, а проверка профиля
сверяла конфиг с тем же значением — все гейты проходили. Админка при этом
берёт из `listen` только порт и всегда идёт на `127.0.0.1`. Валидная по всем
проверкам конфигурация разводила компоненты по разным адресам и выключала
лимит устройств, учёт трафика и принудительное отключение разом: `/online`
недоступен → авторизация fail-closed → не подключается никто. Адрес
зафиксирован на `127.0.0.1`, а админка называет расхождение вместо молчаливой
подстановки loopback.
- **JSON-журнал Hysteria не разбирался ни одной строкой.** Юнит запускает
Hysteria с `HYSTERIA_LOG_FORMAT=json`, но `time` в этом формате — число, и
притом дробное (`EpochMillisTimeEncoder` делит наносекунды на миллисекунду),
поэтому разбор в структуру со строковым полем падал всегда и уходил в
fallback: панель показывала сырой JSON. Замена типа поля на `int64` не
помогла бы. Разбор ведётся по фактическому формату, а структурный контекст
записи (`addr`, `id`, `error`, `listen`, …) больше не выбрасывается, а
дописывается к сообщению в устойчивом порядке и проходит санитайз. Заодно
перестали теряться записи, у которых journald отдаёт `MESSAGE` массивом байт.
- **«Служба остановлена» и «состояние неизвестно» были одним значением.**
`util.Exec` выбрасывает вывод команды при ненулевом коде возврата, а
`systemctl is-active` отвечает словом состояния в stdout вместе с кодом 3 —
прочитать его было нечем. Дашборд из-за этого умел утверждать «Hysteria
остановлена» и «Traffic Stats API доступен» одновременно: доступность API
выводилась из того же ответа systemd, а не из обращения к API. Появился
`util.ExecProbe`, состояние службы стало трёхзначным, а доступность API —
независимым фактом. Список пиров при недоступном API отвечает «онлайн
неизвестен», а не «все офлайн».
- **Страница конфигурации показывала дефолты UI вместо файла.** Ответ сервера
накладывался на полный объект значений по умолчанию, поэтому отсутствующая
секция `trafficStats` показывалась как `:9999`, явное `speedTest: false`
считалось ненастроенным, а `ignoreClientBandwidth` без блока `bandwidth` не
показывался вовсе. Экран, существующий ради диагностики расхождений, эти
расхождения скрывал. Теперь панель показывает записанные значения (отличая
«не задано» от значения) и отдельно перечисляет секции вне production-профиля.
Три редактора, которые ничего не сохраняли, удалены вместе с их компонентами.
- **Читающий экран отдавал больше секретов, чем выгрузка.** Пароль обфускации,
токены ACME DNS, учётные данные outbound-прокси и masquerade уезжали в
браузер в открытом виде, хотя санитизированный экспорт того же конфига их
вырезает. Теперь вместо значения показывается диагностический факт: «задан» /
«не задан», имена параметров без значений, auth-URL с вырезанным токеном.
- **Секрет за YAML-якорем покидал сервер.** Санитайзер выгрузки не обрабатывал
`yaml.AliasNode`: значение по ссылке оставалось нетронутым, а объявление
якоря стояло под несекретным именем ключа — секрет уезжал в файл дважды.
Обход идёт по цели ссылки, с защитой от циклов: `yaml.v3` на ссылке,
указывающей на предка, строит действительно циклический граф узлов.
- **Hysteria больше не проверяет обновления сама.** В сборочном и e2e окружении
`HYSTERIA_DISABLE_UPDATE_CHECK=1` стоял, а в production-юните — нет. Версией
владеет один источник (`versions.env` → сборка → пакет → оркестратор), и
production не имеет права отличаться от тестового окружения.
- **Удалены мёртвые остатки прежней архитектуры.** `util.CompareVersion`
(лексикографическое сравнение версий без потребителя: `2.10 < 2.9`),
`service.ReleaseHysteria2` (пустая заглушка, вызывавшаяся при завершении
сервиса), `PeerClientConfigVo.QrCode` (второй канал доставки QR, который
панель рисует сама), компонент `UnitSelect` и три функции `utils/byte.ts`.
- **Гейт освобождения admission-замка проверял форму, а не замок.**
`/defer\s+\w+\(\)/` означало «в функции есть какой-нибудь отложенный вызов» и
пережило бы `defer someOtherCleanup()` рядом со взятым замком. Теперь имя
переменной берётся из самого присваивания.
- **Панель считала wildcard нормальным адресом control plane.** Признак на
странице конфигурации отвечал только на вопрос «достучится ли админка»,
поэтому `0.0.0.0` показывался без замечаний — хотя внутренний Traffic Stats
API при нём опубликован на всех интерфейсах, а оркестратор такой конфигурации
не создаёт. Теперь показываются три состояния: канон профиля, wildcard
(предупреждение) и недостижимый адрес (ошибка); backend при этом по-прежнему
отвечает только на вопрос достижимости — превращать лишнюю публикацию в отказ
обслуживания значило бы отключить всех пиров. Заодно исправлено ложное
утверждение в комментарии `parseTrafficStatsPort`: пустой хост `:36712` — это
все интерфейсы, а не loopback.
- **«Какой-нибудь loopback» не означает «достижим по 127.0.0.1».** Проверка
принимала любой `ip.IsLoopback()`, то есть считала рабочим и `127.0.0.5`.
Слушатель на конкретном адресе принимает соединения только на него
(`bind 127.0.0.5` + `dial 127.0.0.1` = connection refused, проверено), а слой
proxy обращается строго к `http://127.0.0.1:<port>`: такой адрес выглядел
локальным, ломал контур доступа целиком, и админка об этом молчала.
Принимаются ровно `127.0.0.1`, `0.0.0.0` и пустой хост; свойство закреплено
тестом с настоящими сокетами, а гейт приёмки запрещает возврат `IsLoopback()`
и требует негативного случая в тестах.
### Исправлено — устаревшие утверждения приёмки
- **Гейт «frontend ACME registry matches current upstream» описывал снятую
архитектуру.** Он требовал, чтобы страница Hysteria содержала жёсткий список
ACME DNS-провайдеров (`cloudflare``vultr`) и не содержала удалённого
upstream `namedotcom`. Это имело смысл, пока панель ПРЕДЛАГАЛА выбор
провайдера: список в UI был вторым экземпляром upstream-реестра и мог от него
отстать. После перевода страницы в read-only диагностику реестра нет и быть не
должно — имя провайдера читается из фактического конфига и показывается как
есть, поэтому новый upstream-провайдер отображается без правок панели.
Возврат списка ради прохождения grep'а создал бы фиктивный реестр,
существующий только для гейта, поэтому исправлен гейт: он проверяет
действующий контракт (провайдер приходит строкой и рисуется как значение,
параметры DNS — только именами, селектора на странице нет). То же утверждение
поправлено в матрице приёмки, а контрактный тест панели теперь требует
ОТСУТСТВИЯ всех восьми имён провайдеров в исходнике страницы.
### Исправлено — зависимости frontend
- **`browserslist@4.21.11` содержал high-advisory** (уязвимы `<= 4.28.6`) и
приходил транзитивно через `autoprefixer` и `update-browserslist-db`. Гейт
сборки отработал ровно так, как задуман: `pnpm audit` по всему lock-графу
остановил релиз до создания tarball. Закрыто точечным `pnpm.overrides` на
`4.28.7` — точной версией, а не диапазоном: security-патч обязан быть
детерминированным и не тащить за собой чужой major. Обновилось только
поддерево `browserslist` (`caniuse-lite`, `electron-to-chromium`, `escalade`,
`node-releases`, `update-browserslist-db`); `autoprefixer`, Vite и остальной
граф не тронуты.
### Исправлено — гейты сборки
- **Гейт fail-open срабатывал на корректном коде.** Проверка «авторизация не
возвращает успех из ветки ошибки» была записана регуляркой
`err != nil \{[\s\S]*?return \*peer\.Id`, а ленивый `[\s\S]*?` свободно
пересекает границы блоков: она давала совпадение на любой функции, где после
какой-нибудь проверки ошибки ниже стоит успешный возврат. Проверено на коде
из `HEAD` — гейт нельзя было удовлетворить, не сломав продукт. Тело ветки
теперь выделяется по балансу фигурных скобок.
- **Детектор гонок стал обязательным шагом сборки.** Состояние трекера
разрешений и мьютекс цикла учёта принадлежат процессу, поэтому их
корректность не наблюдаема ни в `go test`, ни в `go vet`. Пропуск при
недоступном C-компиляторе не предусмотрен: сборка, молча пропускающая
проверку, выдаёт внешне неотличимый production-артефакт.
### Исправлено — отзыв доступа к VPN (десятый проход)
- **Отключение пира не отзывало доступ.** Запись `disabled=1` видит только
выборка в `Hysteria2Auth`, то есть она закрывает БУДУЩИЕ обращения к
HTTP-auth; установленная QUIC-сессия живёт своей жизнью и сама не
разрывается. После «Отключить» пир продолжал пользоваться доступом сколько
угодно долго, пока не переподключался по своей воле, — а панель показывала
его отключённым. Официальная документация Hysteria описывает `/kick` и
блокировку в auth backend как пару: по отдельности не работает ни одна
половина.
Появился отдельный примитив разрыва — только официальный Traffic Stats
`/kick`, без единой записи в базу. Прежний `Hysteria2Kick` вместе с разрывом
проставлял `banned_until`, поэтому воспользоваться им для отключения было
нельзя: операция записала бы заодно временную блокировку — другой механизм с
другим сроком жизни. (В одиннадцатом проходе он принимает готовые `authId`, а
не идентификаторы пиров: удалению и импорту старое значение нужно уже после
его исчезновения из базы.)
Порядок обратному не подлежит: сначала долговременная запись, затем разрыв.
При обратном клиент успевает переподключиться в окне между `/kick` и записью.
Порядок доказывается снимком состояния базы В МОМЕНТ прихода `/kick` — после
операции оба шага уже выполнены и проверять там нечего.
- **Неудача разрыва не откатывает запрет.** Безопасная половина достигнута, и
возвращать пиру полный доступ из-за отказа второго шага нельзя. Частичный
результат сообщается отдельным кодом `peer_disconnect_failed`: без него
оператор прочитал бы обычную ошибку как «не сработало, состояние прежнее» —
вывод, прямо противоположный истине. Панель показывает его предупреждением и
обновляет строку.
- **Временная блокировка отчитывалась отказом, будучи применённой.** `KickPeer`
писал `banned_until`, затем звал `Hysteria2Kick`, который писал то же
значение второй раз, а до этого мог отказать на проверке состояния службы —
не сделав ничего, но уже после первой записи. Обе операции переведены на один
примитив.
- **`Hysteria2IsRunning` убран с путей принятия решений.** `util.Exec`
схлопывает «systemctl вернул 3, служба неактивна» и «запустить systemctl не
удалось» в одну ошибку, поэтому его ответ не является основанием ни для
отказа операции, ни для её пропуска. Значению осталось место в отображении,
где ошибочное «выключено» стоит одной неверной плашки.
- **Ограничение устройств стало fail-closed.** Отказ `/online` обрабатывался
возвратом успеха авторизации, то есть недоступность внутреннего `127.0.0.1`
превращала объявленный в панели «Лимит устройств: 3» в безлимит; следом была
только строка `warn` в журнале.
Вторая половина той же дыры была тише: `Hysteria2Online` отдавал ПУСТУЮ КАРТУ
БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», — и лимит переставал
проверяться вообще без единой записи. Пути разделены по назначению: терпимый
для отображения, строгий для решения о доступе.
Направление выбрано осознанно: запрос авторизации приходит ОТ Hysteria,
значит она жива, а её Traffic Stats API слушает loopback внутри того же
процесса. У `maxDevices` есть `min=1`, безлимита не бывает, поэтому такой
отказ затрагивает всех пиров сразу — и пишется уровнем `error`.
- **Секрет Traffic Stats API читался без проверки на nil** прямо на пути
machine-auth, то есть при каждом подключении пира: строка в таблице `config`
без значения уронила бы админку паникой на разыменовании.
- **Ошибка публичного порта больше не называет транспорт.** Публичный endpoint
Hysteria — UDP/QUIC, а сообщение утверждало `must be a valid TCP port`.
### Исправлено — журнал и страницы, которые его показывают
- **Журнал запросов писал пустой `msg`.** `entry.Info()`, `entry.Warn()` и
`entry.Error()` вызывались без аргумента сообщения, и logrus честно
записывал `"msg":""` для каждого HTTP-запроса. Пустой столбец на странице
системных логов был точным отражением того, что записал backend, а не
рассогласованием модели отображения с форматом файла.
`middleware.RequestLogMessage` собирает строку из тех же величин, что уже
лежат в структурных полях. Query-строка в неё не попадает: контракт
безопасности сильнее удобства чтения, и тест проверяет обе половины сразу —
сообщение непустое И не несёт ни machine token, ни знака `?`.
- **Страница системных логов не работала, пока нет файла журнала.** Эта ветка
отвечала голым массивом вместо `{records, total}`, панель читала
`data.records` и передавала `undefined` в таблицу. То есть на
свежепоставленном хосте — ровно в сценарии приёмки чистой установки —
страница была сломана целиком.
- **Одна битая строка ломала весь ответ.** При неразбираемой записи
выполнялись `vo.Fail(...)` И `continue`: ответ уже записан в поток, цикл идёт
дальше, а в конце безусловно выполняется `vo.Success`. Клиент получал два
JSON-документа подряд. Строка при этом бьётся штатно — lumberjack ротирует
файл, и обрыв последней записи на границе ротации обычен. Теперь такая строка
пропускается: остальные записи прочитаны и полезны.
### Исправлено — панель оператора, второй заход
- **Быстрого включения/отключения пира в меню не было вовсе.** Сменить
состояние можно было только через форму изменения, хотя это самое частое
действие оператора.
- **Меню действий переведено на `command`.** `@click` на каждом
`el-dropdown-item` заменён штатным контрактом `el-dropdown`: команда
приходит в одно место, и добавить пункт, забыв его подключить, становится
невозможно.
- **Колонки таблиц журнала не задавали ширину.** Без `width`/`min-width`
Element Plus делит доступную ширину практически поровну, и сообщение —
единственное содержимое журнала — получало треть строки. Служебные колонки
зафиксированы, колонка сообщения растягивается за счёт остатка и
переносится, а не обрезается многоточием: у Hysteria в `msg` приезжает
диагностический JSON.
- **Обе страницы журнала построены на общем `LogViewer`.** Они были побайтово
одинаковы и несли одни и те же дефекты в двух экземплярах; починка в двух
местах разошлась бы снова.
- **Отказ выгрузки журнала не ловился и глушился.** Сетевой запрос стоял ПЕРЕД
`try`, поэтому его отказ этим `catch` не ловился вовсе, а сам `catch` был
пуст. Оператор нажимал «Экспорт» и не получал ни файла, ни причины.
- **Сборка ссылки на скачивание сведена в `utils/download.ts`.** Копий было
четыре, и все успели разойтись: две ставили запрос вне `try`, третья падала
на `split(...)` при отсутствующем `Content-Disposition` — и это исключение
тоже глушилось. Четвёртую копию (выгрузка конфига Hysteria) нашёл
контрактный тест, потребовавший единственности `createObjectURL`.
- **Ширина всплывающей подсказки ограничена штатным `popper-style`.** Без
ограничения длинный перевод получал естественную ширину popper и
растягивался почти на весь экран одной строкой.
- **Срок временной блокировки называется оператору.** `Date.now() + час` был
зашит в обработчик, не подтверждался и не сообщался ни до, ни после; ключи
локализации для него существовали и были мёртвыми. Пункты переименованы так,
чтобы «временно заблокировать» не путалось с «отключить пир».
- **Отмена подтверждения перестала быть ошибкой.** `ElMessageBox` отклоняет
промис при нажатии «Отмена», и `await ElMessageBox.confirm(...)` без разбора
отказа оставлял необработанное отклонение промиса — в четырёх местах страницы
пиров и ещё одном в верхней панели.
- **Подсказка имени пира описывала не действующее правило.** Она обещала
«латиница, цифры и дефис», тогда как `service.IsValidPeerName` принимает
`a-z A-Z 0-9 !@#$%^&*()_+-=`: подсказка осталась от правила, действовавшего
до EX-03, и оператор не имел причин пробовать разрешённые символы. Тест
читает набор из серверной константы `PeerNameCharset`.
- **Типы полей журнала в панели расходились с сервером** (`latencyTime` и
`statusCode` объявлены строками при `int64` на сервере). Пока колонки не
отображались, расхождение было безвредным.
- **Диагностика слоя proxy сообщала `err: <nil>` именно при отказе API.**
Ветка `err != nil || resp.StatusCode != http.StatusOK` печатала `err`, то
есть при ответе Hysteria с кодом, отличным от 200, в журнал уезжала запись
без единого факта. Различать эти два случая стало обязательно: на этом пути
теперь стоит отзыв доступа.
- **Отказ показывался дважды, а транспортный — ни разу.** Действия строки пира
не объявляли `skipErrorToast`, поэтому после появления собственных сообщений
отказ показывался и перехватчиком, и страницей: у частичного результата
отзыва доступа это давало предупреждение «состояние применено наполовину» и
рядом ошибку — два противоречащих сообщения об одном событии.
Обратная половина существовала и раньше: флаг гасил не только отказ API, но и
ТРАНСПОРТНЫЙ отказ, при том что все объявлявшие его страницы писали в своих
обработчиках «транспортный отказ уже показан общим перехватчиком» и молчали.
Обрыв соединения при сохранении или удалении пира не показывал оператору
ничего. Флаг отнесён только к отказу API — тому, у которого есть конверт с
`code` и `errors`, то есть есть что разбирать.
- **Из globals ESLint убраны `DialogType` и `OptionType`** — их нет ни в
`src/types/global.d.ts`, ни где-либо в панели; список приведён к
фактическому содержимому файла объявлений. Добавлены `IdDto`, `BaseDto` и
`PageVo`, объявленные там на самом деле: `no-undef` не знает о
`declare global`, и отсутствующее в списке имя падало ошибкой линтера на
совершенно корректном коде.
### Исправлено — панель оператора
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
«оставьте пустым — сгенерируем автоматически» и отправляла `secret: ""`.
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
объявлено указателем и указатель не nil: помощник `hasValue` считает
указатель на пустую строку «значением». Правило `min=6` применялось к пустой
строке и отказывало, а генерация в `CreatePeer` оставалась недостижимой.
Ловушка закрыта механизмом, а не тегом на одном поле: между разбором тела и
проверкой правил появился шаг нормализации DTO (`dto.Normalizable`). Граница
проходит по каждому полю отдельно — у `remark` пустая строка означает
«убрать пометку», у `disabled` ноль означает «включён», и общее правило
«пусто → не задано» молча сломало бы оба.
Той же ловушкой ломался фильтр списка пиров: `el-input` с крестиком очистки
ставит пустую строку, axios сериализует её как `?name=`, и поиск отказывал в
один клик по крестику.
- **Генерация секрета названа явным шагом сервисного слоя.**
`service.GeneratePeerSecret` на базе `util.RandomString` (`crypto/rand` с
отбрасыванием смещённых байтов) используется и формой, и импортом: пир,
созданный панелью, и пир, импортированный без секрета, теперь неотличимы.
- **Любая ошибка любого поля превращалась в слово `invalid`.** Слой `vo` при
этом определял код ответа СРАВНЕНИЕМ текста сообщения с тремя литералами —
тот же антипаттерн, который запрещён панели, только на сервере. Ответ об
ошибке теперь несёт `errors: [{code, field, message, params}]`; панель
выбирает локализованную фразу по коду и подставляет причины под поля формы.
Границы числа и границы длины строки различаются кодом, хотя тег валидатора
у них один: оператору это разные фразы.
- **Истечение сессии не обрабатывалось.** Сервер отвечает HTTP 200 на любой
отказ, поэтому обработчик ошибок axios для отказов API не вызывался вовсе —
а ветка сессии жила именно там; её условие проверяло `code === "A0230"` и
поле `msg`, которых в этом API никогда не было. Вдобавок истёкший токен уезжал
с кодом системной ошибки. Теперь `ParseToken` возвращает объявленные значения
ошибок вместо свежих строк, middleware различает истечение и
недействительность через `errors.Is`, а панель показывает диалог и
возвращает на форму входа — один раз, даже когда истёкший токен уронил
несколько параллельных запросов страницы.
- **Обработчик транспортных ошибок падал сам.** Он читал `error.response.data`,
не проверив `error.response`, и при обрыве соединения подменял настоящую
причину `TypeError` внутри себя.
- **Сброс сессии больше не зовёт `localStorage.clear()`**, который заодно стирал
выбранный оператором язык панели.
- **`id` требовался и в пути, и в теле запроса.** `PeerUpdateDto` встраивал
`IdDto` с правилом `required`, хотя значение из тела всё равно затирается
значением из пути. Заодно убрана недостижимая запасная ветка `resolveID`,
читавшая идентификатор из тела: она вызывала разбор тела, которое обработчик
читает следом второй раз, а gin его не буферизует.
### Исправлено — отрисовка иконок
- **Контракт `currentColor` был объявлен и не действовал.** `fill: currentcolor`
стоял и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`, но восемь из
семнадцати ассетов несли литеральный `fill="#000000"` прямо на `<path>`, а
атрибут представления перебивает унаследованное CSS-свойство. Под это
попадали все семь иконок бокового меню на фоне `#181818`.
Литеральный цвет убран из ассетов; многоцветные объявлены явным списком;
преобразование в `<symbol>` и контракт ассета вынесены в чистый модуль
`SvgIcon/symbol.ts`, который можно выполнить вне Vite и DOM — и, значит,
проверить. Цвета в рантайме НЕ переписываются: молчаливая нормализация
скрывала бы ровно тот дефект, который контракт обязан делать видимым.
- **У `SvgIcon` убран проп цвета** и атрибут `fill` на `<use>`: он приглашал
чинить отрисовку точечно в обход общего контракта.
### Исправлено — правила имени пира
- **Два правила на одном поле противоречили друг другу.** Стояли
`min=1,max=32` и `validateStr`, требовавший 6-32 символа: имя из трёх
символов проходило одно правило и отказывалось на другом. Длина перенесена
внутрь одного правила.
- **Набор символов в слое контроллеров впускал `, - . / : ; <`.** Копия правила
несла неэкранированный дефис, из-за чего `+-=` образовывал ДИАПАЗОН; её
комментарий при этом утверждал, что набор тот же, что у импорта. Через панель
проходило имя `peer/name`, которое импорт того же пира отклонял, — при том что
имя уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
дверями в таблицу пиров.
Набор символов ЛОГИНА администратора сознательно не сужен: он записан явно,
но повторяет прежнее фактическое множество. Имя администратора приходит из
`HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение правила
означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
оператора в панель. Закреплено отдельным тестом, чтобы попытка «навести
порядок» роняла сборку, а не вход на живом сервере.
### Добавлено — атрибуция и контрактные тесты панели
- **Подпись «Разработано во Flamy»** внизу бокового меню, ссылкой фирменным
цветом. Адрес объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API,
ни из таблицы `config`. Высота области прокрутки меню вычитает высоту
подписи, поэтому пункты меню не могут на неё наехать.
- **Контрактные тесты панели** (`tools/test/frontend-*.test.ts`) стали
обязательным шагом сборки наравне с тестами оркестратора и админки: контракт
спрайта иконок, совпадение наборов ключей `ru` и `en`, соответствие кодов
ошибок серверным константам, единственность адреса атрибуции.
Их исполняет уже закреплённый в `versions.env` Bun, а не vitest: jsdom не
вычисляет `currentColor` и визуальной корректности всё равно не доказал бы,
зато vitest привёл бы в граф `pnpm audit` — а его порог считается по всему
lock-файлу frontend — сотню транзитивных зависимостей ради нулевой
дополнительной гарантии.
### Изменено — документация
- **`docs/` разложена по слоям** вместо плоской кучи из четырнадцати файлов:
`architecture/`, `build/`, `runtime/`, `admin/`, `operations/`, `testing/`,
`acceptance/`. Двузначный префикс сохранён как стабильный идентификатор
документа — под ним на него ссылаются CHANGELOG, релизные гейты и сообщения
оркестратора.
- **`11-testing-and-acceptance.md` (117 КБ, 57 разделов) разбит на пять частей**
по слоям, на которых выполняются проверки.
- **Добавлен `docs/acceptance/`** — отчёты о фактических прогонах приёмки,
отдельно от описания самих проверок. Документ проверок переживает релизы;
отчёт о прогоне относится к одному артефакту и одному хосту и после
публикации не редактируется. Первый отчёт — build/host acceptance
`v1.0.0-rc1` на Debian 13 с перечнем найденных дефектов и их закрытия.
- **Добавлен `docs/admin/15-ui-contracts.md`** — контракты панели, которые не
проверяются ни типами, ни сборкой bundle.
- **Зафиксировано требование к памяти build-хоста:** `govulncheck` строит граф
достижимости по всему модулю вместе со stdlib, и на машине с ~1.9 GiB RAM без
swap он был убит OOM killer.
- **Обход документации в приёмке стал рекурсивным.** Плоский шаблон
`docs/*.md` после разнесения по каталогам совпадал бы ровно с одним файлом,
то есть проверка отчитывалась бы зелёным, не заглянув почти никуда.
### Исправлено — гейты сборки ### Исправлено — гейты сборки
- **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.** - **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.**
@@ -1069,7 +1746,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути
установки и данных берутся из конфигурации, а не захардкожены. установки и данных берутся из конфигурации, а не захардкожены.
- **`tools/legacy/purge-v0.sh`** и [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — - **`tools/legacy/purge-v0.sh`** и [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) —
явная очистка сервера от предыдущего поколения. По умолчанию скрипт явная очистка сервера от предыдущего поколения. По умолчанию скрипт
показывает план и ничего не делает; выполнение требует показывает план и ничего не делает; выполнение требует
`--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло `--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло
@@ -1143,7 +1820,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема — - **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не `apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — там это имя чужого [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — там это имя чужого
артефакта, который очистка должна найти. артефакта, который очистка должна найти.
- **Индикатор загрузки и legacy-цвета переведены на брендовый токен.** - **Индикатор загрузки и legacy-цвета переведены на брендовый токен.**
@@ -1210,7 +1887,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
существует. Номера оставшихся миграций сохранены: перенумерация заставила бы существует. Номера оставшихся миграций сохранены: перенумерация заставила бы
их примениться повторно. их примениться повторно.
В `docs/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они В `docs/operations/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
обозначают реальные объекты, которые нужно удалить с сервера. Из остальных обозначают реальные объекты, которые нужно удалить с сервера. Из остальных
v1-доков этот словарь убран. v1-доков этот словарь убран.
@@ -1326,7 +2003,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
1. Выпишите с работающего сервера список пиров и их секреты. 1. Выпишите с работающего сервера список пиров и их секреты.
2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из 2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md). [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
3. Разверните `1.0.0` на чистом Debian 13 из release-пакета. 3. Разверните `1.0.0` на чистом Debian 13 из release-пакета.
4. Заведите пиров заново и раздайте новые клиентские ссылки. 4. Заведите пиров заново и раздайте новые клиентские ссылки.
+28 -16
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‑пароль админки
@@ -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` |
@@ -818,7 +818,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 +928,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 +997,25 @@ 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. прогоняет контрактные тесты панели (спрайт иконок, словари локализации, коды ошибок, атрибуция);
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. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); 7. прогоняет `go vet` и `go test` для HY2XS admin;
8. формирует архив и прогоняет acceptance‑проверки. 8. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу);
9. формирует архив и прогоняет acceptance‑проверки.
Любой сбой на шагах 1–7 останавливает сборку до создания пакета. Любой сбой на шагах 1–8 останавливает сборку до создания пакета.
Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка Тесты и типы (шаги 2, 3 и 7) — такой же обязательный гейт, как проверка
зависимостей: переменной, которая их отключает, не существует. Готовый пакет зависимостей: переменной, которая их отключает, не существует. Готовый пакет
объявляет об этом полем `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 не создают.
Переменные, управляющие выбором версии Hysteria: Переменные, управляющие выбором версии Hysteria:
@@ -1087,9 +1090,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 +1108,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 {
+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)
}
+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)
}
}
+62 -18
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"strconv" "strconv"
@@ -18,19 +19,32 @@ 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 {
vo.FailValidation(
"идентификатор пира в адресе некорректен",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Field: "id",
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
}},
c,
)
return 0, errors.New(constant.ErrCodeBodyInvalid)
}
return parsed, nil return parsed, nil
} }
}
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return 0, err
}
return *idDto.Id, nil
}
func Login(c *gin.Context) { func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{}) loginDto, err := validateField(c, dto.LoginDto{})
@@ -39,6 +53,14 @@ func Login(c *gin.Context) {
} }
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass) token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil { if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c) vo.Fail(err.Error(), c)
return return
} }
@@ -50,12 +72,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 +87,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 +98,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 +128,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 +186,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
} }
@@ -198,8 +234,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 +295,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)
+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)
}
}
+183 -17
View File
@@ -1,47 +1,213 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"reflect"
"regexp"
"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/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)
} }
func validateStr(f validator.FieldLevel) bool { func mustRegister(tag string, fn validator.Func) {
if err := validate.RegisterValidation(tag, fn); err != nil {
panic(fmt.Sprintf("не удалось зарегистрировать правило %q: %v", tag, err))
}
}
// validatePeerName — единственное правило имени пира.
//
// Набор символов и длина берутся из service: имя пира проверяется на двух
// дверях в одну и ту же таблицу — обычное создание и импорт выгрузки, — и две
// независимые копии правила уже расходились. Копия в слое контроллеров
// выглядела так:
//
// ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$
//
// и её комментарий утверждал, что набор тот же, что у импорта. Он был другим:
// дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и
// впускает `, - . / 0-9 : ; < =`. То есть через панель проходило имя
// `peer/name`, которое импорт того же самого пира отклонял, — а имя пира
// уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
func validatePeerName(f validator.FieldLevel) bool {
return service.IsValidPeerName(f.Field().String())
}
// credentialStrPattern — набор символов логина и пароля администратора.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
// пира.
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
func validateCredentialStr(f validator.FieldLevel) bool {
field := f.Field().String() field := f.Field().String()
// Строка должна быть длиной 6-32 символа и состоять из букв, цифр или разрешённых спецсимволов return field == "" || credentialStrPattern.MatchString(field)
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
compile := regexp.MustCompile(reg)
return field == "" || compile.MatchString(field)
} }
// 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 ||
c.Request.Method == http.MethodPatch ||
c.Request.Method == http.MethodDelete {
bindErr = c.ShouldBindJSON(&field) 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":
described.Code = constant.ErrCodeCredentialStr
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
default:
described.Code = constant.ErrCodeRuleUnknown
described.Params = map[string]string{"rule": fieldErr.Tag()}
described.Message = fmt.Sprintf("поле %q не удовлетворяет правилу %q", field, fieldErr.Tag())
}
return described
}
+78
View File
@@ -0,0 +1,78 @@
package controller
import (
"strings"
"testing"
"hy2xs-admin/service"
)
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
//
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
// набор символов не ограничивает, и сужение правила означало бы, что установка
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
//
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
// классе символов уронит его, а не вход администратора на живом сервере.
func TestCredentialCharsetIsUnchanged(t *testing.T) {
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
for _, symbol := range strings.Split(historical, "") {
candidate := "admin" + symbol
if !credentialStrPattern.MatchString(candidate) {
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
}
}
for _, rejected := range []string{
"admi", // короче шести символов
strings.Repeat("a", 33), // длиннее тридцати двух
"admin пробел", // пробел
"админ1", // кириллица
"admin\n1", // перевод строки
"admin'1", // апостроф вне набора
} {
if credentialStrPattern.MatchString(rejected) {
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
}
}
}
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
// одну таблицу и не имеют права требовать разного.
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)
}
}
}
+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: {
+2 -1
View File
@@ -72,7 +72,8 @@
"brace-expansion": ">=1.1.18", "brace-expansion": ">=1.1.18",
"picomatch": ">=2.3.2", "picomatch": ">=2.3.2",
"js-yaml": ">=4.3.1", "js-yaml": ">=4.3.1",
"table>ajv": ">=8.18.0" "table>ajv": ">=8.18.0",
"browserslist": "4.28.7"
} }
}, },
"engines": { "engines": {
+39 -24
View File
@@ -11,6 +11,7 @@ overrides:
picomatch: '>=2.3.2' picomatch: '>=2.3.2'
js-yaml: '>=4.3.1' js-yaml: '>=4.3.1'
table>ajv: '>=8.18.0' table>ajv: '>=8.18.0'
browserslist: 4.28.7
importers: importers:
@@ -1175,6 +1176,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 +1192,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 +1215,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 +1352,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 +1399,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:
@@ -1865,8 +1874,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==}
@@ -2392,11 +2402,11 @@ 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.28.7
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -3435,7 +3445,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 +3465,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 +3477,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 +3504,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 +3624,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 +3701,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: {}
@@ -4134,7 +4149,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: {}
@@ -4733,11 +4748,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:
@@ -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",
+91 -176
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-профиль не описывает. */
drift: string[];
}
export interface Hysteria2ProfileAuth {
type: string | null;
/** Адрес backend-auth без machine token: он вырезан на сервере. */
url: string | null;
insecure: boolean | null;
}
export interface Hysteria2ProfileTls {
cert: string | null;
key: string | null;
sniGuard: string | null;
clientCA: string | null;
}
export interface Hysteria2ProfileAcme {
domains: string[]; domains: string[];
email: string; email: string | null;
ca: string; ca: string | null;
listenHost: string; dir: string | null;
dir: string; listenHost: string | null;
type?: string; type: string | null;
http?: { dnsProvider: string | null;
altPort: number; /** Имена параметров DNS-провайдера; значения на сервер не отдаются. */
}; dnsConfigKeys: string[];
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 Hysteria2ProfileObfs {
name: string; type: string | null;
type: string; /**
socks5?: { * Пароль обфускации в панель не приходит: он входит в клиентскую ссылку и
addr: string; * выдаётся там, где нужен. Диагностичен только факт, что он задан.
username?: string; */
password?: string; passwordSet: boolean;
}; minPacketSize: number | null;
http?: { maxPacketSize: number | null;
url: string;
insecure: boolean;
};
direct?: {
mode: string;
bindIPv4?: string;
bindIPv6?: string;
bindDevice?: string;
fastOpen?: boolean;
};
} }
export interface Tab { export interface Hysteria2ProfileBandwidth {
name: string; up: string | null;
desc: string; 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;
+135 -214
View File
@@ -44,8 +44,18 @@ 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 +100,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 +122,75 @@ 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}”: contains characters that are not allowed",
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 +206,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: 6 to 32 characters — latin letters, digits and !@#$%^&*()_+-=. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "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 +248,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 +287,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 +310,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.",
},
},
}, },
}; };
+154 -169
View File
@@ -43,8 +43,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 +100,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 +122,82 @@ 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}. Пробелы, кириллица и знаки / : ; . недопустимы",
credential_format: "«{field}»: недопустимые символы",
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 +212,16 @@ export default {
remark: "Комментарий", remark: "Комментарий",
secret: "Секрет", secret: "Секрет",
form: { form: {
namePlaceholder: "например, ivan-laptop", namePlaceholder: "client-01",
// Набор символов назван так, как его понимает service.IsValidPeerName.
// Прежняя формулировка «латиница, цифры и дефис» осталась от правила,
// действовавшего до EX-03: подсказка обещала более узкий набор, чем
// сервер принимает, и оператор не имел причин пробовать остальные.
nameHint: nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", "Идентификатор пира: от 6 до 32 символов — латиница, цифры и знаки !@#$%^&*()_+-=. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: апример, Ноутбук Ивана, отдел продаж", remarkPlaceholder: оутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.", remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически", secretPlaceholder: "оставьте пустым — сгенерируем",
secretHint: secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.", "Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.", quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
@@ -164,21 +256,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 +294,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 +318,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",
},
},
}, },
}; };
+13 -6
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 {
await ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
confirmButtonText: t("navbar.logout"), confirmButtonText: t("navbar.logout"),
cancelButtonText: t("common.cancel"), cancelButtonText: t("common.cancel"),
type: "warning", type: "warning",
}).then(() => { });
adminStore.logout().then(() => { } catch {
return;
}
await adminStore.logout();
router.push(`/login?redirect=${route.fullPath}`); 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']
+116
View File
@@ -0,0 +1,116 @@
/**
* Разбор структурированного отказа 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",
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;
}
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: any) => { (error: AxiosError) => {
if (error.response.data) { // Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос,
const { code, msg } = error.response.data; // HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки
// Token истёк, нужен повторный вход // самого error.response — то есть при обрыве соединения падал с
if (code === "A0230") { // TypeError и подменял настоящую причину отказом внутри обработчика.
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), { const message = error.response
confirmButtonText: t("common.confirm"), ? t("common.systemError")
type: "warning", : t("common.networkError");
}).then(() => {
localStorage.clear(); // skipErrorToast здесь НЕ действует, и это существенно.
window.location.href = "/"; //
}); // Флаг существует ради одного случая: страница разбирает причину отказа
} else { // сама — раскладывает её по полям формы или отличает частичный результат
ElMessage.error(msg || "Системная ошибка"); // от полного отказа по коду. Всё это возможно только для отказа API, у
} // которого есть конверт с `code` и `errors`.
} //
return Promise.reject(error.message); // У транспортного отказа конверта нет вовсе. Страница о нём не может
// сказать ничего, кроме того же «сервер не отвечает», и все они пишут в
// своих обработчиках `// транспортный отказ уже показан общим
// перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было
// ложным: обрыв соединения при сохранении пира, его удалении или отзыве
// доступа не показывал оператору НИЧЕГО — операция просто не происходила
// молча.
ElMessage.error(message);
return Promise.reject(error);
} }
); );
+66 -3
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,24 @@
>{{ $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"
@@ -183,7 +222,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 +277,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
+13 -98
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>
+14 -98
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>
+474 -61
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,29 @@
: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") }}</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 +268,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 +364,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 +414,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 +435,41 @@ 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";
/**
* Ограничение ширины всплывающих подсказок.
*
* Объявлено значением, а не литералом в шаблоне: подсказок в панели больше
* одной, и «широкая» подсказка это свойство панели, а не отдельной кнопки.
*/
const TOOLTIP_POPPER_STYLE = {
maxWidth: "360px",
whiteSpace: "normal",
lineHeight: "1.5",
} as const;
/** Срок временной блокировки по умолчанию — один час. */
const KICK_DURATION_MS = 60 * 60 * 1000;
/** Действия меню строки пира. */
type PeerCommand =
| "qr"
| "edit"
| "toggle-disabled"
| "reset-traffic"
| "kick"
| "release-kick"
| "delete";
/** /**
* Единственный переход от строки слота таблицы к модели пира. * Единственный переход от строки слота таблицы к модели пира.
@@ -396,6 +497,14 @@ const { t } = useI18n();
const loading = ref(false); const 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 +539,56 @@ 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];
}
}
}
);
// Зеркало серверного контракта, а не второй его экземпляр: границы и набор
// символов заданы в service.IsValidPeerName и dto.PeerSaveDto, и расхождение
// здесь приводит лишь к лишнему запросу, а не к принятому некорректному пиру.
const PEER_NAME_PATTERN = /^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$/;
const SECRET_MIN_LENGTH = 6;
const SECRET_MAX_LENGTH = 128;
const rules = { const rules = {
name: [ name: [
{ {
@@ -437,6 +596,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: 6,
max: 32,
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
}),
trigger: ["change", "blur"],
},
],
secret: [
{
// Пустое поле законный ввод: секрет сгенерирует сервер. Проверяется
// только НЕПУСТОЕ значение.
validator: (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
const manual = (value ?? "").trim();
if (manual === "") {
callback();
return;
}
if (manual.length < SECRET_MIN_LENGTH) {
callback(
new Error(
t("error.code.min_length", {
field: t("error.field.secret"),
min: SECRET_MIN_LENGTH,
})
)
);
return;
}
if (manual.length > SECRET_MAX_LENGTH) {
callback(
new Error(
t("error.code.max_length", {
field: t("error.field.secret"),
max: SECRET_MAX_LENGTH,
})
)
);
return;
}
callback();
},
trigger: ["change", "blur"],
},
], ],
}; };
@@ -475,12 +685,17 @@ async function handleQuery() {
const { data } = await pagePeerApi(queryParams); 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 +712,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,21 +721,30 @@ 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;
} }
try {
if (dialog.editId > 0) { if (dialog.editId > 0) {
const payload: PeerUpdateDto = { const payload: PeerUpdateDto = {
id: dialog.editId, id: dialog.editId,
name: dataForm.name, name: dataForm.name,
// Пустой секрет при изменении означает «не менять», и сервер читает
// его именно так. Отправлять undefined больше не требуется, но и вреда
// в этом нет: оба состояния для него теперь одинаковы.
secret: dataForm.secret || undefined, secret: dataForm.secret || undefined,
quotaBytes: dataForm.quotaBytes, quotaBytes: dataForm.quotaBytes,
expiresAt: dataForm.expiresAt, expiresAt: dataForm.expiresAt,
@@ -529,45 +754,221 @@ async function submitForm() {
}; };
await updatePeerApi(payload); await updatePeerApi(payload);
} else { } else {
// Секрет отправляется как есть, включая пустую строку: автогенерация
// обязанность сервера, а не подстановка значения здесь.
await savePeerApi(dataForm); 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;
); }
try {
await deletePeerApi({ id: row.id }); 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) {
try {
await releaseKickPeerApi({ id: row.id }); 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 +977,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);
try {
await importPeerApi(formData); await importPeerApi(formData);
ElMessage.success(t("common.success"));
} catch (error) {
reportPeerActionError(error);
}
importFileList.value = []; importFileList.value = [];
await handleQuery(); await handleQuery();
} }
@@ -602,20 +1020,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 +1038,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 +1048,9 @@ async function handleExportBackup() {
confirmButtonClass: "el-button--danger", confirmButtonClass: "el-button--danger",
} }
); );
if (!confirmed) {
return;
}
await downloadExport(true); await downloadExport(true);
} }
+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)
}
}
+70
View File
@@ -12,3 +12,73 @@ 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"
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"
)
+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"`
} }
+81
View File
@@ -0,0 +1,81 @@
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)
}
// zeroToNil: ноль у необязательного числового поля означает «не задано».
//
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
// «показать 0 строк журнала» и «время начала — 1 января 1970 года» — это
// пропуск фильтра, а не запрос.
func zeroToNil[T int | int64](field **T) {
if *field == nil {
return
}
if **field == 0 {
*field = nil
}
}
+122
View File
@@ -0,0 +1,122 @@
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)
}
+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 {
+13
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"`
+33 -1
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
} }
+74 -12
View File
@@ -1,15 +1,30 @@
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"`
Errors []FieldError `json:"errors,omitempty"`
Data interface{} `json:"data"` Data interface{} `json:"data"`
} }
@@ -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)
+17 -1
View File
@@ -25,13 +25,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 {
+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()
}
+256 -71
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() { // секунд, а очередь из накопившихся циклов ничего бы не дала.
if !accountJobMutex.TryLock() {
return return
} }
defer accountJobMutex.Unlock()
apiPort, err := GetHysteria2ApiPort() apiPort, err := GetHysteria2ApiPort()
if err != nil { if err != nil {
logrus.WithError(err).Error("cron: не удалось определить порт Traffic Stats API; цикл учёта пропущен")
return return
} }
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) // Секрет берётся общей функцией, которая отличает «ключа нет» от пустого
// значения. Раньше здесь стояло `*trafficSecretConfig.Value` без единой
// проверки: строка в таблице `config` без значения роняла бы процесс
// паникой на разыменовании nil — причём внутри отсоединённой горутины, где
// её некому перехватить, то есть падал бы весь сервис вместе с
// обработчиком machine-auth.
secret, err := hysteria2TrafficSecret()
if err != nil { if err != nil {
logrus.WithError(err).Error("cron: секрет Traffic Stats API недоступен; цикл учёта пропущен")
return return
} }
// Сохранение данных трафика // Порядок обязателен: enforcement принимает решение по счётчикам, поэтому
go saveAccountTraffic(apiPort, *trafficSecretConfig.Value) // счётчики должны быть уже обновлены.
if err := saveAccountTraffic(apiPort, secret); err != nil {
// Принудительное отключение logrus.WithError(err).Error("cron: сбор трафика завершился с ошибкой")
go kickAccount(apiPort, *trafficSecretConfig.Value)
}()
} }
if err := enforcePeerAccess(apiPort, secret); err != nil {
logrus.WithError(err).Error("cron: принудительное отключение завершилось с ошибкой")
}
}
// CronResetTraffic обнуляет счётчики трафика всех пиров по расписанию.
func CronResetTraffic() { 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 {
return &trafficLossError{lost: lost}
}
return nil
}
// trafficLossError сообщает, сколько дельт не удалось записать.
//
// Отдельный тип, а не fmt.Errorf, потому что количество здесь — величина, а не
// украшение фразы: чтение `?clear=1` деструктивно, поэтому «потеряно 1 из 200»
// и «потеряно 200 из 200» — разные события, и различать их должен уметь не
// только человек, читающий журнал.
type trafficLossError struct{ lost int }
func (e *trafficLossError) Error() string {
return fmt.Sprintf(
"дельт трафика не записано и потеряно безвозвратно: %d",
e.lost,
)
}
// enforcePeerAccess приводит ЖИВЫЕ СЕССИИ в соответствие с сохранённым
// состоянием.
//
// Политика берётся из peerAccessDenied — той же функции, по которой пира
// пускает или не пускает авторизация. Собственного SQL-условия здесь больше
// нет, и это главное свойство: пока правило было записано в двух местах, оно
// расходилось на границах quota, expiry и ban, и исчерпавший квоту пир не
// пускался заново, но и не отключался никогда.
//
// Обход идёт по КАЖДОМУ authID, который Hysteria считает живым, а не по
// найденным в базе пирам. Прежняя реализация читала
//
// peers, err := dao.ListPeer("auth_id in ?", chunk)
// for _, peer := range peers { ... }
//
// и потому не видела сессий, которым в базе больше ничего не соответствует.
// Это не теоретический случай: `auth_id` перезаписывает импорт, а строку
// целиком убирает удаление. Обе операции рвут старую сессию сами, но их второй
// шаг может не удаться — и тогда единственным местом, где о ней ещё известно,
// остаётся сам `/online`. Пропуская незнакомый идентификатор молча, cron
// оставлял такую сессию жить неограниченно долго. Подробности — в
// peer_session-разделе peer_access.go.
//
// Отказ базы НЕ приводит к разрыву. «Пира нет» и «прочитать не удалось» —
// разные ответы, и второй не даёт права рвать ничьи сессии: недоступная SQLite
// иначе означала бы отключение всех подключённых пиров сразу. Ошибка чтения
// прекращает цикл до единого обращения к `/kick`.
//
// Обход последовательный. Прежняя реализация раскладывала online-пиров на
// чанки по 10 и запускала по горутине на чанк с sync.WaitGroup внутри уже
// отсоединённой горутины. Параллельность здесь не нужна: обращений к базе
// столько же, а `/kick` всё равно один на весь набор.
func enforcePeerAccess(apiPort int64, trafficStatsSecret string) error {
online, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(trafficStatsSecret)
if err != nil { if err != nil {
return return err
} }
if len(users) > 0 {
i := 0 // Учёт выданных разрешений чистится по фактической картине подключений, и
authIDs := make([]string, len(users)) // это единственное место продукта, где она известна целиком. Делается это
for k := range users { // до любых решений: уборка ни на что не влияет и ничего не рвёт.
authIDs[i] = k sweepDeviceAdmissions(online, time.Now())
i++
if len(online) == 0 {
return nil
} }
authIDLists := util.SplitArr(authIDs, 10)
var wg sync.WaitGroup authIDs := make([]string, 0, len(online))
for _, authIDList := range authIDLists { for authID := range online {
wg.Add(1) // Пустой ключ ничему не соответствует: рвать по нему нечего, и в
go func(authIDList []string) { // dedup disconnectAuthIDs он всё равно не попал бы.
defer wg.Done() 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() now := time.Now().UnixMilli()
peers, err := dao.ListPeer(`auth_id in ? and ( kick := make([]string, 0, len(authIDs))
disabled = 1 for _, authID := range authIDs {
or (quota_bytes > 0 and quota_bytes < download_bytes + upload_bytes) peer, found := known[authID]
or (expires_at > 0 and ? > expires_at) if !found {
or ? < banned_until // Сессия, которой в базе больше ничего не соответствует: пир удалён
)`, authIDList, now, now) // либо его идентификатор заменён импортом, а разрыв в тот момент не
if err != nil { // удался. Восстановить такое состояние переподключением нельзя —
return // авторизация нового значения не знает, — поэтому единственный
// правильный исход тот же, что и у первой попытки.
logrus.WithField("authId", authID).
Warn("cron: живая сессия без пира в базе; сессия завершается")
kick = append(kick, authID)
continue
} }
kickAuthIDs := make([]string, len(peers)) if peerSessionNeedsReconcile(peer, online[authID], now) {
j := 0 kick = append(kick, authID)
for _, item := range peers {
kickAuthIDs[j] = *item.AuthId
j++
}
if err = proxy.NewHysteria2Api(apiPort).KickUsers(kickAuthIDs, trafficStatsSecret); err != nil {
return
}
}(authIDList)
}
wg.Wait()
} }
} }
// Пустой набор до `/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 {
+95 -6
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"errors" "errors"
"strings"
"hy2xs-admin/util" "hy2xs-admin/util"
) )
@@ -12,7 +13,7 @@ import (
// InitHysteria2 проверяет единственное, что панели действительно нужно от // InitHysteria2 проверяет единственное, что панели действительно нужно от
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и // Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
// у которого спрашивает состояние Hysteria2IsRunning. // у которого спрашивает состояние HysteriaServiceState.
// //
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели, // Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
// в которой панель сама запускала и останавливала Hysteria: clean install // в которой панель сама запускала и останавливала Hysteria: clean install
@@ -26,11 +27,99 @@ func InitHysteria2() error {
return nil return nil
} }
func Hysteria2IsRunning() bool { // Состояние службы имеет ТРИ значения, а не два.
_, err := util.Exec("systemctl is-active --quiet hysteria-server") //
return err == nil // Что было. Ответ сводился к `bool`:
//
// func Hysteria2IsRunning() bool {
// _, err := util.Exec("systemctl is-active --quiet hysteria-server")
// return err == nil
// }
//
// `false` при этом означал сразу две несовместимые вещи — «служба неактивна» и
// «спросить не получилось», — потому что util.Exec выбрасывает вывод команды,
// как только код возврата не нулевой, а `is-active` отвечает именно кодом 3
// вместе со словом состояния в stdout. Прочитать это слово было нечем.
//
// Цена такой неопределённости оказалась не в одной неверной плашке. Пока
// «неизвестно» выглядело как «остановлена», на этом значении стояли решения:
// цикл учёта и проверка лимита устройств пропускались при сломанном systemctl
// и живой Hysteria. Оба гейта убраны (см. cron.go и Hysteria2Auth), и это
// правильно, но сама неопределённость никуда не делась — она осталась в
// диагностике, где как раз и вредна:
//
// Hysteria stopped
// Traffic Stats API reachable
// 0 online users
//
// Три утверждения об одной системе, из которых первое и второе не могут быть
// верны одновременно. Оператор, увидев такое в аварии, ищет не там.
//
// Теперь состояние читается словом, а не кодом возврата, и неизвестность
// называется неизвестностью.
type HysteriaServiceState string
const (
// HysteriaServiceActive — systemd сообщил "active".
HysteriaServiceActive HysteriaServiceState = "active"
// HysteriaServiceInactive — systemd назвал любое другое известное
// состояние: inactive, failed, activating, deactivating.
//
// Различать их дальше продукту незачем: панель отвечает на вопрос «идёт ли
// сейчас обслуживание», а не пересказывает конечный автомат systemd.
HysteriaServiceInactive HysteriaServiceState = "inactive"
// HysteriaServiceUnknown — спросить не удалось либо ответ не разобран.
//
// Это НЕ «служба остановлена». Единственное честное следствие — что о
// состоянии службы сейчас ничего не известно.
HysteriaServiceUnknown HysteriaServiceState = "unknown"
)
// Hysteria2ServiceState — ответ systemd о состоянии службы, и НЕ БОЛЬШЕ ТОГО.
//
// Значение годится для отображения и ни для чего больше. Решения о доступе
// принимаются по фактическому ответу Traffic Stats API — см. hysteria2Online и
// disconnectAuthIDs, — и это правило стоило продукту трёх дыр: гейт
// `if !Hysteria2IsRunning() { return }` в cron молча отключал и сбор трафика, и
// принудительное отключение при сломанном systemctl.
func Hysteria2ServiceState() HysteriaServiceState {
return hysteriaServiceState()
} }
func ReleaseHysteria2() error { // hysteriaServiceState — та же проверка в виде значения.
return nil //
// Переменная нужна тестам: без неё каждая проверка, зависящая от состояния
// службы, доказуема только на живом systemd, то есть не доказуема в сборке
// вовсе. Продуктовый путь от этого не меняется — значение по умолчанию и есть
// реализация.
var hysteriaServiceState = func() HysteriaServiceState {
// `--quiet` здесь недопустим: он подавляет ровно тот вывод, ради которого
// команда вызывается.
output, _, err := util.ExecProbe("systemctl is-active hysteria-server")
if err != nil {
return HysteriaServiceUnknown
}
return parseSystemdActiveState(output)
}
// parseSystemdActiveState разбирает ответ `systemctl is-active`.
//
// Слово состояния приезжает первой строкой. Неизвестное значение считается
// неизвестным состоянием, а не «неактивна»: список состояний systemd
// пополнялся и раньше, и молчаливое приведение нового слова к «остановлена»
// вернуло бы ровно ту ложь, от которой избавляет этот тип.
func parseSystemdActiveState(output string) HysteriaServiceState {
state := strings.TrimSpace(output)
if idx := strings.IndexAny(state, "\r\n"); idx >= 0 {
state = strings.TrimSpace(state[:idx])
}
switch state {
case "active":
return HysteriaServiceActive
case "inactive", "failed", "activating", "deactivating", "reloading", "maintenance":
return HysteriaServiceInactive
default:
return HysteriaServiceUnknown
}
} }
+205 -35
View File
@@ -7,6 +7,7 @@ import (
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/proxy" "hy2xs-admin/proxy"
"hy2xs-admin/util"
"net" "net"
"net/url" "net/url"
"os" "os"
@@ -48,7 +49,10 @@ func resolvePublicEndpoint() (string, int, error) {
} }
port, err := strconv.Atoi(portRaw) port, err := strconv.Atoi(portRaw)
if err != nil || port < 1 || port > 65535 { if err != nil || port < 1 || port > 65535 {
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid TCP port") // Транспорт в формулировке не называется: публичный endpoint Hysteria —
// UDP/QUIC, и «TCP port» здесь закладывал в сообщение об ошибке
// заведомо ложную семантику.
return "", 0, errors.New("HY2XS_PUBLIC_PORT must be a valid port")
} }
return host, port, nil return host, port, nil
} }
@@ -59,75 +63,241 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
if digestErr != nil { if digestErr != nil {
return 0, "", digestErr return 0, "", digestErr
} }
peer, err := dao.GetPeer(`secret_digest = ? // Поиск идёт ТОЛЬКО по учётным данным. Политика доступа больше не живёт
and disabled = 0 // внутри выборки: её объявляет peerAccessDenied, и ровно её же применяет
and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes) // принудительное отключение в cron. Пока правило было записано двумя
and (expires_at = 0 or ? < expires_at) // разными SQL-условиями, авторизация и enforcement расходились на границах
and ? > banned_until`, secretDigest, now, now) // quota, expiry и ban — см. комментарий в peer_access.go.
peer, err := dao.GetPeer("secret_digest = ?", secretDigest)
if err != nil { if err != nil {
return 0, "", err return 0, "", err
} }
// Ограничение количества устройств if peerAccessDenied(peer, now) {
onlineUsers, err := Hysteria2Online() return 0, "", errors.New("peer access denied")
if err != nil {
logrus.WithError(err).Warn("hysteria2 online users unavailable; skip device-limit check")
return *peer.Id, *peer.AuthId, nil
} }
device, exist := onlineUsers[*peer.AuthId]
if exist && *peer.MaxDevices <= device { // Строка без идентичности — повреждённые данные, а не пир.
//
// Проверка стоит здесь по той же причине, что и проверка maxDevices ниже:
// это путь КАЖДОГО подключения пира, и разыменование nil на нём означает
// панику в обработчике machine-auth, а не отказ одному клиенту.
if peer.Id == nil || peer.AuthId == nil || *peer.AuthId == "" {
logrus.Error("peer row has no usable identity; rejecting auth")
return 0, "", errors.New("peer identity unavailable")
}
// Ограничение количества устройств — fail-closed.
//
// Раньше отказ Traffic Stats API обрабатывался так:
//
// onlineUsers, err := Hysteria2Online()
// if err != nil {
// logrus.WithError(err).Warn(...)
// return *peer.Id, *peer.AuthId, nil
// }
//
// То есть недоступность внутреннего 127.0.0.1 превращала объявленный в
// панели «Лимит устройств: 3» в безлимит, и узнать об этом оператор мог
// только по строке WARN в журнале, которую никто не читает. Ограничение,
// которое отключается само при первой же внутренней неполадке, не является
// ограничением.
//
// Вторая половина той же дыры была тише: общий Hysteria2Online отдавал
// пустую карту БЕЗ ошибки, когда systemd отвечал «служба неактивна», —
// а этот ответ не отличается от «спросить systemctl не удалось». Поэтому
// здесь берётся строгий путь: только фактический ответ Traffic Stats API.
//
// Направление отказа выбрано осознанно. Запрос авторизации приходит ОТ
// Hysteria, то есть в момент этой проверки Hysteria заведомо жива, а её
// Traffic Stats API слушает loopback внутри того же процесса. Его
// недоступность здесь — не штатное состояние, а аномалия, и пускать
// подключения без единственной проверки, которая ещё не выполнена, значит
// молча снять лимит со всех пиров сразу.
// Чтение `/online` и резервация места — ОДНА последовательность, и она
// выполняется под замком этого пира.
//
// Без замка снимки приходили в резервацию в произвольном порядке, и
// устаревший откатывал учёт назад: разрешение, уже признанное проявившимся,
// возвращалось в «свободное место». Подробный разбор — в начале
// peer_admission.go.
//
// Замок берётся именно здесь, а не раньше: до этой точки известен только
// секрет, а сериализовать нужно подключения ОДНОГО пира, то есть замок
// невозможно взять, пока не прочитан его authId. Всё, что выше, — работа с
// базой и политикой доступа, и разным пирам она не мешает.
unlockAdmission := lockPeerAdmission(*peer.AuthId)
defer unlockAdmission()
onlineUsers, err := hysteria2Online()
if err != nil {
logrus.WithError(err).
WithField("peerId", *peer.Id).
Error("hysteria2 traffic stats api unavailable; device limit cannot be enforced, rejecting auth")
return 0, "", errors.New("device limit unavailable")
}
// maxDevices без значения — это не «безлимит», а неизвестная граница.
// Схема даёт колонке DEFAULT, форма требует min=1, импорт приводит <=0 к 3,
// поэтому nil здесь означать может только повреждённую строку — и на пути
// принятия решения о доступе она обязана вести к отказу, а не к пропуску.
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
logrus.WithField("peerId", *peer.Id).
Error("peer has no usable maxDevices; rejecting auth")
return 0, "", errors.New("device limit unavailable")
}
// Место занимается ПОСЛЕ всех остальных проверок и с учётом уже выданных,
// но ещё не проявившихся разрешений — см. peer_admission.go. Сравнение
// одного лишь ответа `/online` пропускало параллельные подключения: между
// чтением и ответом «allow» ничего не удерживало место, и два одновременных
// запроса при `online=2, max=3` получали разрешение оба.
//
// Порядок существенен: если бы резервация делалась раньше проверки
// квоты или срока, отказ по ним съедал бы слот на всё время TTL.
if !reserveDeviceSlot(*peer.AuthId, onlineUsers[*peer.AuthId], *peer.MaxDevices, time.Now()) {
return 0, "", errors.New("device limited") return 0, "", errors.New("device limited")
} }
return *peer.Id, *peer.AuthId, nil return *peer.Id, *peer.AuthId, nil
} }
// Hysteria2Online — картина подключений ДЛЯ ОТОБРАЖЕНИЯ.
//
// Отличается от hysteria2Online ровно ничем, и это результат исправления, а не
// упущение. Раньше здесь стоял ярлык
//
// if !hysteria2IsRunning() {
// return map[string]int64{}, nil
// }
//
// то есть «пусто, ошибки нет» по мнению systemd. У него было два следствия, и
// оба вредные.
//
// Первое — на пути доступа: ответ systemctl не отличает «служба неактивна» от
// «спросить не удалось», а пустая картина при проверке лимита устройств
// означает «пускать всех». Эта половина закрыта раньше — авторизация ходит
// строгим путём.
//
// Второе осталось и живёт в диагностике. Пустая карта БЕЗ ошибки неотличима от
// «никто не подключён», поэтому сборщик метрик выставлял `ApiReachable = true`
// и `OnlineDevices = 0`, ни разу не обратившись к Traffic Stats API, а список
// пиров показывал всех офлайн. Дашборд утверждал одновременно «служба
// остановлена» и «API доступен, онлайн 0» — два несовместимых факта об одной
// системе, полученные из одного и того же ответа systemctl.
//
// Поэтому ярлыка нет: «кто сейчас на связи» спрашивается у того, кто это
// знает. Недоступность остаётся ОШИБКОЙ, а решать, как её показать оператору,
// обязан вызывающий — см. CollectMetricsSnapshot и PagePeer, где она
// превращается в явное «состояние неизвестно», а не в «все офлайн».
//
// Функция сохранена отдельно от hysteria2Online как имя для внешнего слоя:
// внутри пакета строгий путь остаётся строчным.
func Hysteria2Online() (map[string]int64, error) { func Hysteria2Online() (map[string]int64, error) {
if !Hysteria2IsRunning() { return hysteria2Online()
return map[string]int64{}, nil
} }
// hysteria2Online — фактический ответ Traffic Stats API, без ярлыков.
//
// Недоступность здесь остаётся ошибкой: вызывающий обязан решить, что она для
// него значит, и не может получить пустую карту вместо отказа.
func hysteria2Online() (map[string]int64, error) {
apiPort, err := GetHysteria2ApiPort() apiPort, err := GetHysteria2ApiPort()
if err != nil { if err != nil {
return nil, errors.New("get hysteria2 apiPort err") return nil, errors.New("get hysteria2 apiPort err")
} }
secret, err := hysteria2TrafficSecret()
if err != nil {
return nil, err
}
return proxy.NewHysteria2Api(apiPort).OnlineUsers(secret)
}
// hysteria2TrafficSecret отдаёт секрет Traffic Stats API.
//
// Отсутствующее значение ключа — отказ, а не пустая строка. Раньше по этому
// пути стояло `*config.Value` без проверки: строка в таблице `config` без
// значения роняла бы админку паникой на разыменовании nil прямо в обработчике
// machine-auth, то есть на пути каждого подключения пира.
func hysteria2TrafficSecret() (string, error) {
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil { if err != nil {
return nil, err return "", err
} }
onlineUsers, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(*trafficSecretConfig.Value) if trafficSecretConfig.Value == nil || *trafficSecretConfig.Value == "" {
if err != nil { return "", errors.New("hysteria2 traffic stats secret is not configured")
return nil, err
} }
return onlineUsers, nil return *trafficSecretConfig.Value, nil
} }
func Hysteria2Kick(ids []int64, kickUtilTime int64) error { // kickChunkSize ограничивает размер одного обращения к `/kick`.
if !Hysteria2IsRunning() { //
return errors.New("hysteria2 is not running") // Импорт применяет до MaxPeerImportItems записей за операцию, и без разбиения
// в Hysteria уехал бы один POST с многотысячным массивом в теле. Значение
// выбрано с запасом относительно любого реального размера панели: смысл здесь
// не в оптимизации, а в отсутствии запроса, размер которого задаёт содержимое
// пользовательского файла.
const kickChunkSize = 100
// disconnectAuthIDs — ЕДИНСТВЕННЫЙ путь к Traffic Stats `/kick` в продукте.
//
// Контракт предельно узкий и намеренно ничего не знает про пиров:
//
// auth IDs -> дедупликация -> порт API -> секрет -> POST /kick
//
// Никакой базы, никакого `disabled`, никакого `banned_until`. Разрыв сессии и
// запись состояния разделены сознательно: прежний Hysteria2Kick делал и то и
// другое — вместе с обращением к `/kick` он проставлял `banned_until`, — и
// из-за этого им нельзя было воспользоваться для отключения пира: операция
// записала бы заодно временную блокировку, а это другой механизм с другим
// сроком жизни и другим способом снятия.
//
// Вход — именно auth IDs, а не идентификаторы пиров, и это не деталь. Операции
// удаления и импорта меняют или убирают auth ID: после commit действующего
// значения в базе уже нет, и рвать надо по тому, которое Hysteria знала ДО
// операции. Функция, которая сама читала бы auth ID из базы, для этих двух
// путей опоздала бы всегда.
//
// Состояние службы по systemd НЕ проверяется. Ответ systemd не отличает
// «служба неактивна» от «спросить не удалось» (см. Hysteria2IsRunning),
// поэтому сбой самого systemctl отказывал бы операции при живой Hysteria.
// Обращение к `/kick` отвечает на нужный вопрос напрямую и без посредника.
func disconnectAuthIDs(authIDs []string) error {
keys := make([]string, 0, len(authIDs))
seen := make(map[string]struct{}, len(authIDs))
for _, authID := range authIDs {
if authID == "" {
continue
} }
if err := dao.UpdatePeer(ids, map[string]interface{}{"banned_until": kickUtilTime}); err != nil { if _, duplicate := seen[authID]; duplicate {
return err continue
}
seen[authID] = struct{}{}
keys = append(keys, authID)
} }
peers, err := dao.ListPeer("id in ?", ids) // Ни одной цели — значит рвать нечего, и это не отказ. Раньше по
if err != nil { // аналогичному пути в cron уезжал POST с пустым массивом каждые 30 секунд.
return err if len(keys) == 0 {
} return nil
var keys []string
for _, item := range peers {
keys = append(keys, *item.AuthId)
} }
apiPort, err := GetHysteria2ApiPort() apiPort, err := GetHysteria2ApiPort()
if err != nil { if err != nil {
return errors.New("get hysteria2 apiPort err") return errors.New("get hysteria2 apiPort err")
} }
trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret) secret, err := hysteria2TrafficSecret()
if err != nil { if err != nil {
return err return err
} }
if err = proxy.NewHysteria2Api(apiPort).KickUsers(keys, *trafficSecretConfig.Value); err != nil {
api := proxy.NewHysteria2Api(apiPort)
for _, chunk := range util.SplitArr(keys, kickChunkSize) {
if err := api.KickUsers(chunk, secret); err != nil {
return err return err
} }
}
return nil return nil
} }
@@ -176,7 +346,7 @@ func Hysteria2Url(accountId int64) (string, error) {
// несколькими пирами это ровно то различие, которое пользователю и нужно // несколькими пирами это ровно то различие, которое пользователю и нужно
// видеть, и оно не требует ни одной дополнительной настройки. Fallback на // видеть, и оно не требует ни одной дополнительной настройки. Fallback на
// публичный хост нужен для пира без имени — база это допускает (name имеет // публичный хост нужен для пира без имени — база это допускает (name имеет
// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера. // DEFAULT ), а ссылка без имени профиля хуже, чем ссылка с именем сервера.
func shareRemark(peerName *string, hostname string) string { func shareRemark(peerName *string, hostname string) string {
if peerName != nil { if peerName != nil {
if name := strings.TrimSpace(*peerName); name != "" { if name := strings.TrimSpace(*peerName); name != "" {
+75 -12
View File
@@ -61,7 +61,7 @@ func SanitizeHysteria2ConfigYaml(raw string) ([]byte, error) {
return nil, err return nil, err
} }
redactNode(&document, nil) redactNode(&document, nil, newAliasGuard())
out, err := yaml.Marshal(&document) out, err := yaml.Marshal(&document)
if err != nil { if err != nil {
@@ -120,7 +120,53 @@ func isSecretMapPath(path []string) bool {
return false return false
} }
func redactNode(node *yaml.Node, path []string) { // Секрет, спрятанный за YAML-якорем, тоже покидал сервер.
//
// Что было. И redactNode, и redactSubtree разбирали четыре вида узлов:
// документ, последовательность, отображение и скаляр. Пятый вид —
// yaml.AliasNode — не обрабатывался ни одной из них, а yaml.v3 якоря и ссылки
// поддерживает:
//
// shared: &credential VERY_SECRET_VALUE
//
// obfs:
// type: salamander
// salamander:
// password: *credential
//
// Утечек здесь ДВЕ, и обе тихие. Значение под ключом `password` — это
// AliasNode, и redactSubtree на нём был no-op. А сам якорь объявлен под
// ключом `shared`, который секретоподобным не выглядит, поэтому и он
// оставался нетронутым. В выгрузке секрет оказывался дважды.
//
// Канонический конфиг HY2XS якорей не содержит, но экспорт существует ещё и
// для диагностики чужих и правленых руками файлов, а обещание «секреты не
// покидают сервер» либо абсолютно, либо его нет.
//
// Лечится переходом ПО ССЫЛКЕ: alias ведёт к тому же узлу, что и якорь,
// поэтому редакция цели закрывает оба вхождения сразу. Защита от циклов
// обязательна — YAML допускает ссылку на предка, и обход без неё не завершится.
// aliasGuard помнит уже посещённые узлы обхода.
type aliasGuard map[*yaml.Node]struct{}
func newAliasGuard() aliasGuard {
return aliasGuard{}
}
// enter возвращает false, если узел уже посещался в этом обходе.
func (g aliasGuard) enter(node *yaml.Node) bool {
if node == nil {
return false
}
if _, seen := g[node]; seen {
return false
}
g[node] = struct{}{}
return true
}
func redactNode(node *yaml.Node, path []string, guard aliasGuard) {
if node == nil { if node == nil {
return return
} }
@@ -128,18 +174,25 @@ func redactNode(node *yaml.Node, path []string) {
switch node.Kind { switch node.Kind {
case yaml.DocumentNode: case yaml.DocumentNode:
for _, child := range node.Content { for _, child := range node.Content {
redactNode(child, path) redactNode(child, path, guard)
}
case yaml.AliasNode:
// Обход продолжается по цели ссылки: путь остаётся прежним, потому что
// alias стоит ровно там, где стояло бы само значение.
if guard.enter(node) {
redactNode(node.Alias, path, guard)
} }
case yaml.SequenceNode: case yaml.SequenceNode:
for _, child := range node.Content { for _, child := range node.Content {
// Индекс не попадает в путь: правила формулируются по именам полей. // Индекс не попадает в путь: правила формулируются по именам полей.
redactNode(child, path) redactNode(child, path, guard)
} }
case yaml.MappingNode: case yaml.MappingNode:
if isSecretMapPath(path) { if isSecretMapPath(path) {
redactAllScalarValues(node) redactAllScalarValues(node, guard)
return return
} }
for i := 0; i+1 < len(node.Content); i += 2 { for i := 0; i+1 < len(node.Content); i += 2 {
@@ -148,10 +201,10 @@ func redactNode(node *yaml.Node, path []string) {
childPath := append(append([]string{}, path...), key) childPath := append(append([]string{}, path...), key)
if isSecretKey(key) { if isSecretKey(key) {
redactSubtree(value) redactSubtree(value, guard)
continue continue
} }
redactNode(value, childPath) redactNode(value, childPath, guard)
} }
case yaml.ScalarNode: case yaml.ScalarNode:
@@ -182,30 +235,40 @@ func sanitizeURLScalar(node *yaml.Node) {
node.Style = 0 node.Style = 0
} }
func redactSubtree(node *yaml.Node) { func redactSubtree(node *yaml.Node, guard aliasGuard) {
if node == nil { if node == nil {
return return
} }
switch node.Kind { switch node.Kind {
case yaml.ScalarNode: case yaml.ScalarNode:
setRedacted(node) setRedacted(node)
case yaml.AliasNode:
// Редактируется ЦЕЛЬ ссылки: она же является значением якоря, поэтому
// одно действие закрывает и `*alias`, и место объявления `&anchor`.
if guard.enter(node) {
redactSubtree(node.Alias, guard)
}
case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode: case yaml.MappingNode, yaml.SequenceNode, yaml.DocumentNode:
redactAllScalarValues(node) redactAllScalarValues(node, guard)
} }
} }
func redactAllScalarValues(node *yaml.Node) { func redactAllScalarValues(node *yaml.Node, guard aliasGuard) {
switch node.Kind { switch node.Kind {
case yaml.MappingNode: case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 { for i := 0; i+1 < len(node.Content); i += 2 {
redactSubtree(node.Content[i+1]) redactSubtree(node.Content[i+1], guard)
} }
case yaml.SequenceNode, yaml.DocumentNode: case yaml.SequenceNode, yaml.DocumentNode:
for _, child := range node.Content { for _, child := range node.Content {
redactSubtree(child) redactSubtree(child, guard)
} }
case yaml.ScalarNode: case yaml.ScalarNode:
setRedacted(node) setRedacted(node)
case yaml.AliasNode:
if guard.enter(node) {
redactSubtree(node.Alias, guard)
}
} }
} }
+127
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"strings" "strings"
"testing" "testing"
"time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -232,6 +233,132 @@ func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
} }
} }
// --- Якоря и ссылки YAML ------------------------------------------------------
// Секрет, спрятанный за якорем, покидал сервер ДВАЖДЫ.
//
// redactNode и redactSubtree разбирали документ, последовательность,
// отображение и скаляр, но не yaml.AliasNode. Значение под ключом `password`
// было ссылкой — и redactSubtree на ней не делал ничего; сам якорь объявлен под
// ключом `shared`, секретоподобным не выглядящим, — и его не трогал никто.
//
// Канонический конфиг HY2XS якорей не содержит, но выгрузка существует ещё и
// для диагностики правленых руками файлов, а обещание «секреты не покидают
// сервер» либо абсолютно, либо его нет.
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredSecrets(t *testing.T) {
raw := `shared: &credential VERY_SECRET_VALUE
listen: 0.0.0.0:443
obfs:
type: salamander
salamander:
password: *credential
`
sanitized := sanitizeForTest(t, raw)
if strings.Contains(sanitized, "VERY_SECRET_VALUE") {
t.Fatalf("секрет за якорем уехал в выгрузку:\n%s", sanitized)
}
// Документ обязан остаться валидным YAML: ссылка без якоря его сломала бы.
var parsed map[string]any
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
t.Fatalf("выгрузка перестала быть валидным YAML: %v\n%s", err, sanitized)
}
obfs, ok := parsed["obfs"].(map[string]any)
if !ok {
t.Fatalf("секция obfs потеряна:\n%s", sanitized)
}
salamander, ok := obfs["salamander"].(map[string]any)
if !ok {
t.Fatalf("подсекция salamander потеряна: %+v", obfs)
}
if salamander["password"] != RedactedPlaceholder {
t.Fatalf("значение по ссылке не вырезано: %+v", salamander)
}
if parsed["shared"] != RedactedPlaceholder {
t.Fatalf("объявление якоря осталось нетронутым: %+v", parsed["shared"])
}
}
// URL с учётными данными, спрятанный за якорем, — тот же случай.
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredURLCredentials(t *testing.T) {
raw := `shared: &endpoint https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
someFutureUpstreamFeature:
endpoint: *endpoint
`
sanitized := sanitizeForTest(t, raw)
for _, secret := range []string{"svc-p4ss", "endpoint-token"} {
if strings.Contains(sanitized, secret) {
t.Fatalf("секрет %q уехал в выгрузку через якорь:\n%s", secret, sanitized)
}
}
if !strings.Contains(sanitized, "relay.example.com") {
t.Fatalf("адрес вырезан целиком вместо учётных данных:\n%s", sanitized)
}
}
// Ссылка на составной узел: редактируется всё поддерево цели.
func TestSanitizeHysteria2ConfigYaml_RedactsAnchoredMapping(t *testing.T) {
raw := `shared: &creds
username: proxyuser
password: proxy-password
outbounds:
- name: upstream
type: socks5
socks5: *creds
`
sanitized := sanitizeForTest(t, raw)
if strings.Contains(sanitized, "proxy-password") {
t.Fatalf("пароль в составном якоре уехал в выгрузку:\n%s", sanitized)
}
}
// Ссылка на предка образует цикл. Без защиты обход не завершился бы вовсе —
// это отказ обслуживания на выгрузке конфига, а не утечка.
func TestSanitizeHysteria2ConfigYaml_SurvivesRecursiveAlias(t *testing.T) {
raw := `root: &root
password: cycle-secret
self: *root
`
type outcome struct {
sanitized string
err error
}
done := make(chan outcome, 1)
go func() {
out, err := SanitizeHysteria2ConfigYaml(raw)
done <- outcome{sanitized: string(out), err: err}
}()
select {
case got := <-done:
// yaml.v3 строит на такой записи ДЕЙСТВИТЕЛЬНО циклический граф узлов
// (alias указывает на объемлющее отображение) и умеет его же обратно
// сериализовать, поэтому отказ здесь был бы регрессией, а не
// допустимым исходом.
if got.err != nil {
t.Fatalf("выгрузка отказала на рекурсивной ссылке: %v", got.err)
}
if strings.Contains(got.sanitized, "cycle-secret") {
t.Fatalf("секрет в рекурсивной структуре уехал в выгрузку:\n%s", got.sanitized)
}
if !strings.Contains(got.sanitized, RedactedPlaceholder) {
t.Fatalf("секрет исчез вместе со структурой вместо редакции:\n%s", got.sanitized)
}
case <-time.After(5 * time.Second):
t.Fatal("санитайзер зациклился на ссылке, указывающей на предка")
}
}
func TestSanitizeURLValue(t *testing.T) { func TestSanitizeURLValue(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+205
View File
@@ -0,0 +1,205 @@
package service
import (
"sort"
"strings"
"gopkg.in/yaml.v3"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/vo"
)
// Проекция серверного конфига на production-профиль HY2XS.
//
// Зачем она нужна вместо «показать модель целиком». Продуктом является один
// профиль: оркестратор генерирует конфиг из шаблона и сам же проверяет, что
// установленный файл этому профилю соответствует
// (orchestrator/src/steps/configAssertions.ts). Страница конфигурации при этом
// обслуживала одновременно и его, и абстрактный «универсальный сервер
// Hysteria» — с вкладками resolver, sniff, acl, outbounds, masquerade, — то
// есть показывала как настройки то, что в этой установке не используется и не
// поддерживается.
//
// Достраивать её до полноценного редактора всех возможностей upstream
// неправильно: конфигом владеет оркестратор, панель его не пишет, а вопрос
// «почему поведение не такое, как ожидалось» решается не списком всех полей, а
// ответом «что в файле отличается от профиля». Поэтому здесь ровно две вещи:
// значения профиля как они записаны и перечень секций вне профиля.
//
// Полный документ никуда не делся — он выгружается санитизированным экспортом.
// hysteria2ProfileSections — секции верхнего уровня, которые описывает
// production-профиль HY2XS.
//
// Список обязан совпадать с whitelist'ом оркестратора
// (assertNoUnexpectedSections) с точностью до того, что там режимы TLS
// взаимоисключающие, а здесь показываются оба: файл может содержать любой из
// них, и оба принадлежат профилю. Совпадение проверяется гейтом приёмки —
// иначе два списка разъедутся молча, и панель начнёт называть расхождением то,
// что оркестратор считает нормой.
var hysteria2ProfileSections = []string{
"acme",
"auth",
"bandwidth",
"congestion",
"ignoreClientBandwidth",
"listen",
"obfs",
"quic",
"tls",
"trafficStats",
}
// BuildHysteria2Profile читает файл ОДИН раз и отвечает на оба вопроса сразу:
// что записано в секциях профиля и какие секции в профиль не входят.
func BuildHysteria2Profile() (vo.Hysteria2ProfileVo, error) {
raw, err := GetRawHysteria2Config()
if err != nil {
return vo.Hysteria2ProfileVo{}, err
}
var config bo.Hysteria2ServerConfig
if err := yaml.Unmarshal([]byte(raw), &config); err != nil {
return vo.Hysteria2ProfileVo{}, err
}
profile := hysteria2ProfileFromConfig(config)
profile.Drift = hysteria2ConfigDrift(raw)
return profile, nil
}
// hysteria2ConfigDrift перечисляет секции файла вне production-профиля.
//
// Разбор идёт в свободную карту, а не в типизированную модель: секция, о
// которой HY2XS не знает вовсе, обязана попасть в расхождения, а модель её
// молча теряет. Именно такие секции и интересны — они означают, что файл правил
// не оркестратор.
func hysteria2ConfigDrift(raw string) []string {
var document map[string]any
if err := yaml.Unmarshal([]byte(raw), &document); err != nil {
// Файл не разобрался как карта верхнего уровня. Значения профиля уже
// прочитаны типизированной моделью; сказать что-либо о расхождениях
// нельзя, и выдумывать их нечестно.
return []string{}
}
known := make(map[string]struct{}, len(hysteria2ProfileSections))
for _, section := range hysteria2ProfileSections {
known[section] = struct{}{}
}
drift := make([]string, 0)
for key := range document {
if _, ok := known[key]; ok {
continue
}
drift = append(drift, key)
}
// Порядок ключей карты в Go случаен: без сортировки список расхождений
// менялся бы при каждом обновлении страницы.
sort.Strings(drift)
return drift
}
func hysteria2ProfileFromConfig(config bo.Hysteria2ServerConfig) vo.Hysteria2ProfileVo {
profile := vo.Hysteria2ProfileVo{
Listen: config.Listen,
IgnoreClientBandwidth: config.IgnoreClientBandwidth,
Drift: []string{},
}
if auth := config.Auth; auth != nil {
profile.Auth = &vo.Hysteria2ProfileAuthVo{Type: auth.Type}
if auth.HTTP != nil {
// URL несёт machine token в query. Санитайзер тот же, что и у
// выгрузки: адрес остаётся читаемым, секрет — нет.
if auth.HTTP.URL != nil {
sanitized := sanitizeURLValue(*auth.HTTP.URL)
profile.Auth.Url = &sanitized
}
profile.Auth.Insecure = auth.HTTP.Insecure
}
}
if tls := config.TLS; tls != nil {
profile.Tls = &vo.Hysteria2ProfileTlsVo{
Cert: tls.Cert,
Key: tls.Key,
SniGuard: tls.SNIGuard,
ClientCA: tls.ClientCA,
}
}
if acme := config.ACME; acme != nil {
profile.Acme = &vo.Hysteria2ProfileAcmeVo{
Domains: acme.Domains,
Email: acme.Email,
Ca: acme.CA,
Dir: acme.Dir,
ListenHost: acme.ListenHost,
Type: acme.Type,
DnsConfigKeys: []string{},
}
if acme.DNS != nil {
profile.Acme.DnsProvider = acme.DNS.Name
for key := range acme.DNS.Config {
profile.Acme.DnsConfigKeys = append(profile.Acme.DnsConfigKeys, key)
}
sort.Strings(profile.Acme.DnsConfigKeys)
}
if profile.Acme.Domains == nil {
profile.Acme.Domains = []string{}
}
}
if obfs := config.Obfs; obfs != nil {
profile.Obfs = &vo.Hysteria2ProfileObfsVo{Type: obfs.Type}
// Показывается ветка, соответствующая объявленному типу. Наличие
// «второй» ветки рядом — это уже расхождение конфига, и его видно по
// самому типу: оркестратор пишет ровно одну.
share := config.ObfsShare()
profile.Obfs.PasswordSet = strings.TrimSpace(share.Password) != ""
if obfs.Gecko != nil && obfs.Type != nil && *obfs.Type == "gecko" {
profile.Obfs.MinPacketSize = obfs.Gecko.MinPacketSize
profile.Obfs.MaxPacketSize = obfs.Gecko.MaxPacketSize
}
}
if bandwidth := config.Bandwidth; bandwidth != nil {
profile.Bandwidth = &vo.Hysteria2ProfileBandwidthVo{
Up: bandwidth.Up,
Down: bandwidth.Down,
DisableLossCompensation: bandwidth.DisableLossCompensation,
}
}
if congestion := config.Congestion; congestion != nil {
profile.Congestion = &vo.Hysteria2ProfileCongestionVo{
Type: congestion.Type,
BbrProfile: congestion.BBRProfile,
}
}
if quic := config.QUIC; quic != nil {
profile.Quic = &vo.Hysteria2ProfileQuicVo{
InitStreamReceiveWindow: quic.InitStreamReceiveWindow,
MaxStreamReceiveWindow: quic.MaxStreamReceiveWindow,
InitConnReceiveWindow: quic.InitConnectionReceiveWindow,
MaxConnReceiveWindow: quic.MaxConnectionReceiveWindow,
MaxIdleTimeout: quic.MaxIdleTimeout,
MaxIncomingStreams: quic.MaxIncomingStreams,
DisablePathMTUDiscovery: quic.DisablePathMTUDiscovery,
DisableStatelessReset: quic.DisableStatelessReset,
}
}
if stats := config.TrafficStats; stats != nil {
profile.TrafficStats = &vo.Hysteria2ProfileTrafficStatsVo{
Listen: stats.Listen,
SecretSet: stats.Secret != nil && strings.TrimSpace(*stats.Secret) != "",
}
}
return profile
}
+321
View File
@@ -0,0 +1,321 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
)
// Страница конфигурации показывает файл, а не представление о файле.
//
// Прежний ответ отдавал внутреннюю модель целиком, а панель накладывала его на
// полный объект дефолтов. Проверки ниже фиксируют обе половины исправления:
// отсутствие секции остаётся отсутствием, а секции вне production-профиля
// называются расхождением, а не теряются.
func writeHysteriaConfig(t *testing.T, content string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
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 })
}
// canonicalProfileConfig — то, что генерирует оркестратор.
const canonicalProfileConfig = `listen: 0.0.0.0:443
acme:
domains:
- vpn.example.com
email: admin@example.com
ca: letsencrypt
listenHost: 0.0.0.0
dir: /var/lib/hysteria/acme
type: http
auth:
type: http
http:
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
insecure: false
obfs:
type: gecko
gecko:
password: gecko-obfs-secret
minPacketSize: 512
maxPacketSize: 1200
bandwidth:
up: 50 mbps
down: 50 mbps
disableLossCompensation: false
ignoreClientBandwidth: false
congestion:
type: bbr
bbrProfile: standard
trafficStats:
listen: 127.0.0.1:36712
secret: traffic-stats-secret
quic:
initStreamReceiveWindow: 8388608
maxStreamReceiveWindow: 8388608
initConnReceiveWindow: 20971520
maxConnReceiveWindow: 20971520
maxIdleTimeout: 30s
maxIncomingStreams: 1024
disablePathMTUDiscovery: false
disableStatelessReset: false
`
// profileJSON сериализует ответ ровно так, как его получит браузер.
//
// Проверять секреты по полям недостаточно: утечка происходит в СЕРИАЛИЗАЦИИ, и
// новое поле без `json:"-"` не заметит ни одна проверка по именам.
func profileJSON(t *testing.T, profile vo.Hysteria2ProfileVo) string {
t.Helper()
encoded, err := json.Marshal(profile)
if err != nil {
t.Fatalf("ответ не сериализуется: %v", err)
}
return string(encoded)
}
func TestBuildHysteria2ProfileReadsCanonicalConfig(t *testing.T) {
writeHysteriaConfig(t, canonicalProfileConfig)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.Listen == nil || *profile.Listen != "0.0.0.0:443" {
t.Fatalf("listen: %v", profile.Listen)
}
if profile.TrafficStats == nil || profile.TrafficStats.Listen == nil ||
*profile.TrafficStats.Listen != "127.0.0.1:36712" {
t.Fatalf("trafficStats.listen: %+v", profile.TrafficStats)
}
if profile.TrafficStats.SecretSet != true {
t.Fatal("наличие секрета Traffic Stats API не показано")
}
if profile.IgnoreClientBandwidth == nil || *profile.IgnoreClientBandwidth {
t.Fatalf("ignoreClientBandwidth: %v", profile.IgnoreClientBandwidth)
}
if profile.Congestion == nil || profile.Congestion.BbrProfile == nil ||
*profile.Congestion.BbrProfile != "standard" {
t.Fatalf("congestion: %+v", profile.Congestion)
}
if profile.Quic == nil || profile.Quic.MaxIdleTimeout == nil || *profile.Quic.MaxIdleTimeout != "30s" {
t.Fatalf("quic: %+v", profile.Quic)
}
if len(profile.Drift) != 0 {
t.Fatalf("канонический конфиг объявлен расходящимся: %v", profile.Drift)
}
}
// Отсутствующая секция остаётся отсутствующей.
//
// Главная регрессия: без `trafficStats` панель показывала `:9999` — адрес,
// которого в файле нет и никогда не было. Экран, существующий ради
// диагностики, скрывал ровно то состояние, которое надо диагностировать:
// «Traffic API не работает» и «трафик не собирается» объясняются именно этим.
func TestBuildHysteria2ProfileKeepsMissingSectionsMissing(t *testing.T) {
writeHysteriaConfig(t, "listen: 0.0.0.0:443\n")
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.TrafficStats != nil {
t.Fatalf("отсутствующая секция trafficStats придумана: %+v", profile.TrafficStats)
}
if profile.Acme != nil || profile.Tls != nil {
t.Fatal("отсутствующая конфигурация TLS придумана")
}
if profile.Obfs != nil || profile.Bandwidth != nil || profile.Congestion != nil || profile.Quic != nil {
t.Fatal("отсутствующие секции придуманы")
}
if profile.IgnoreClientBandwidth != nil {
t.Fatalf("отсутствующий ignoreClientBandwidth придуман: %v", *profile.IgnoreClientBandwidth)
}
}
// Явное `false` — это значение, а не отсутствие.
//
// Прежняя панель приводила секции к `!!data.speedTest` и `!!data.disableUDP`,
// поэтому явно выключенная опция выглядела ненастроенной и прятала свою
// вкладку целиком.
func TestBuildHysteria2ProfileDistinguishesFalseFromMissing(t *testing.T) {
writeHysteriaConfig(t, "listen: \":443\"\nignoreClientBandwidth: false\n")
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.IgnoreClientBandwidth == nil {
t.Fatal("явное false прочитано как «не задано»")
}
if *profile.IgnoreClientBandwidth {
t.Fatal("значение искажено")
}
}
// Секции вне профиля перечисляются поимённо — это и есть диагностика дрейфа.
func TestBuildHysteria2ProfileReportsDrift(t *testing.T) {
writeHysteriaConfig(t, canonicalProfileConfig+`
masquerade:
type: proxy
proxy:
url: https://example.com
resolver:
type: udp
outbounds:
- name: upstream
type: direct
someFutureUpstreamFeature:
enabled: true
`)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
want := []string{"masquerade", "outbounds", "resolver", "someFutureUpstreamFeature"}
if len(profile.Drift) != len(want) {
t.Fatalf("расхождения: %v, ожидалось %v", profile.Drift, want)
}
for i, section := range want {
if profile.Drift[i] != section {
t.Fatalf("расхождения перечислены не по порядку: %v", profile.Drift)
}
}
}
// Секция, о которой HY2XS не знает вовсе, обязана попасть в расхождения:
// типизированная модель её теряет, а именно такие секции и означают, что файл
// правил не оркестратор.
func TestBuildHysteria2ProfileReportsUnknownSections(t *testing.T) {
writeHysteriaConfig(t, "listen: \":443\"\ntotallyUnknownSection:\n a: 1\n")
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if len(profile.Drift) != 1 || profile.Drift[0] != "totallyUnknownSection" {
t.Fatalf("неизвестная секция не названа расхождением: %v", profile.Drift)
}
}
// Ни один секрет не покидает сервер через читающий экран.
//
// Раньше `auth` и `trafficStats.secret` были закрыты `json:"-"`, а пароль
// обфускации, токены ACME DNS и учётные данные outbound/masquerade — нет.
// Скачиваемая выгрузка того же конфига их вырезает, и читающий экран не имеет
// права быть щедрее.
func TestBuildHysteria2ProfileCarriesNoSecrets(t *testing.T) {
writeHysteriaConfig(t, canonicalProfileConfig+`
outbounds:
- name: upstream
type: socks5
socks5:
addr: 10.0.0.1:1080
username: proxyuser
password: proxy-password
`)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
encoded := profileJSON(t, profile)
for _, secret := range []string{
"gecko-obfs-secret",
"traffic-stats-secret",
"machine-secret",
"proxy-password",
} {
if strings.Contains(encoded, secret) {
t.Fatalf("секрет %q уехал в браузер:\n%s", secret, encoded)
}
}
// При этом диагностические факты сохранены: пароль задан, адрес auth
// известен.
if profile.Obfs == nil || !profile.Obfs.PasswordSet {
t.Fatalf("факт «пароль обфускации задан» потерян: %+v", profile.Obfs)
}
if profile.Auth == nil || profile.Auth.Url == nil ||
!strings.Contains(*profile.Auth.Url, "127.0.0.1:8080/internal/hysteria/auth") {
t.Fatalf("адрес backend-auth потерян: %+v", profile.Auth)
}
}
// Имена параметров ACME DNS диагностичны, значения — нет.
func TestBuildHysteria2ProfileShowsAcmeDnsKeysWithoutValues(t *testing.T) {
writeHysteriaConfig(t, `listen: ":443"
acme:
domains:
- vpn.example.com
type: dns
dns:
name: cloudflare
config:
cloudflare_api_token: super-secret-token
zone: example.com
`)
profile, err := BuildHysteria2Profile()
if err != nil {
t.Fatalf("построение профиля отказало: %v", err)
}
if profile.Acme == nil || profile.Acme.DnsProvider == nil || *profile.Acme.DnsProvider != "cloudflare" {
t.Fatalf("провайдер DNS потерян: %+v", profile.Acme)
}
want := []string{"cloudflare_api_token", "zone"}
if len(profile.Acme.DnsConfigKeys) != len(want) {
t.Fatalf("имена параметров DNS: %v", profile.Acme.DnsConfigKeys)
}
for i, key := range want {
if profile.Acme.DnsConfigKeys[i] != key {
t.Fatalf("имена параметров DNS перечислены не по порядку: %v", profile.Acme.DnsConfigKeys)
}
}
if strings.Contains(profileJSON(t, profile), "super-secret-token") {
t.Fatal("значение параметра DNS уехало в браузер")
}
}
// Отсутствие файла — отказ, а не пустой профиль: «конфига нет» и «конфиг пуст»
// для оператора разные ответы.
func TestBuildHysteria2ProfileFailsWithoutFile(t *testing.T) {
previous := constant.Hysteria2ConfigPath
constant.Hysteria2ConfigPath = filepath.Join(t.TempDir(), "missing.yaml")
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
if _, err := BuildHysteria2Profile(); err == nil {
t.Fatal("отсутствующий конфиг выдан за пустой профиль")
}
}
+174
View File
@@ -0,0 +1,174 @@
package service
import (
"net/http"
"testing"
"hy2xs-admin/model/vo"
)
// Дашборд не имеет права утверждать физически несовместимые вещи.
//
// Что было:
//
// Hysteria stopped
// Traffic Stats API reachable
// 0 online users
//
// Все три строки выводились из ОДНОГО ответа systemctl: общий Hysteria2Online
// при неактивной службе отдавал пустую карту без ошибки, и сборщик метрик
// выставлял `apiReachable = true`, ни разу не сходив в API.
//
// Матрица ниже проверяет все четыре клетки: два источника, два исхода каждый.
func TestParseSystemdActiveState(t *testing.T) {
cases := []struct {
name string
output string
want HysteriaServiceState
}{
{"active", "active\n", HysteriaServiceActive},
{"inactive", "inactive\n", HysteriaServiceInactive},
{"failed", "failed\n", HysteriaServiceInactive},
{"activating", "activating\n", HysteriaServiceInactive},
{"deactivating", "deactivating\n", HysteriaServiceInactive},
{"пробелы по краям", " active ", HysteriaServiceActive},
{"несколько строк", "active\nactive\n", HysteriaServiceActive},
{"пустой ответ", "", HysteriaServiceUnknown},
// Список состояний systemd пополнялся и раньше. Молчаливое приведение
// незнакомого слова к «остановлена» вернуло бы ровно ту ложь, ради
// которой заведено третье значение.
{"неизвестное состояние", "quantum\n", HysteriaServiceUnknown},
{"текст ошибки вместо состояния", "Failed to connect to bus\n", HysteriaServiceUnknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseSystemdActiveState(tc.output); got != tc.want {
t.Fatalf("parseSystemdActiveState(%q) = %q, ожидалось %q", tc.output, got, tc.want)
}
})
}
}
// Клетка 1: служба активна, API отвечает.
func TestDashboardStateActiveServiceReachableApi(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
withHysteriaServiceState(t, HysteriaServiceActive)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if snapshot.Hysteria.ServiceState != string(HysteriaServiceActive) || !snapshot.Hysteria.Running {
t.Fatalf("состояние службы: %+v", snapshot.Hysteria)
}
if !snapshot.Hysteria.ApiReachable {
t.Fatal("API отвечает, но объявлен недоступным")
}
if snapshot.OnlineDevices != 2 || snapshot.OnlinePeers != 1 {
t.Fatalf("картина подключений: пиров %d, устройств %d", snapshot.OnlinePeers, snapshot.OnlineDevices)
}
}
// Клетка 2: служба неактивна, API молчит. Оба факта согласованы, и оба верны.
func TestDashboardStateInactiveServiceUnreachableApi(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
withHysteriaServiceState(t, HysteriaServiceInactive)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if snapshot.Hysteria.ServiceState != string(HysteriaServiceInactive) {
t.Fatalf("состояние службы объявлено как %q", snapshot.Hysteria.ServiceState)
}
if snapshot.Hysteria.ApiReachable {
t.Fatal("недоступный Traffic Stats API объявлен доступным")
}
}
// Клетка 3 — главная регрессия: спросить systemctl не удалось, но Hysteria
// жива и API отвечает.
//
// Прежний путь показывал «служба остановлена» и «API доступен» одновременно,
// причём второе — не сходив в API. Теперь неизвестность названа неизвестностью,
// а доступность API получена фактическим обращением.
func TestDashboardStateUnknownServiceReachableApi(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
withHysteriaServiceState(t, HysteriaServiceUnknown)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if snapshot.Hysteria.ServiceState != string(HysteriaServiceUnknown) {
t.Fatalf("неизвестное состояние выдано за %q", snapshot.Hysteria.ServiceState)
}
if snapshot.Hysteria.Running {
t.Fatal("неизвестное состояние выдано за работающую службу")
}
if !snapshot.Hysteria.ApiReachable {
t.Fatal("мнение systemd подменило фактический ответ Traffic Stats API")
}
if snapshot.OnlineDevices != 1 {
t.Fatalf("картина подключений потеряна: устройств %d", snapshot.OnlineDevices)
}
}
// Клетка 4: служба активна, но API отказывает — например, Hysteria ещё
// поднимается или порт занят чужим процессом.
func TestDashboardStateActiveServiceFailingApi(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
withHysteriaServiceState(t, HysteriaServiceActive)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if !snapshot.Hysteria.Running {
t.Fatal("активная служба объявлена остановленной")
}
if snapshot.Hysteria.ApiReachable {
t.Fatal("отказ API объявлен доступностью")
}
if snapshot.HysteriaState.Status != "error" {
t.Fatalf("отказ API не попал в состояние данных: %+v", snapshot.HysteriaState)
}
}
// «Состояние неизвестно» и «служба остановлена» — разные сообщения оператору с
// разной срочностью и разными действиями.
func TestDashboardSecurityRisksDistinguishUnknownFromStopped(t *testing.T) {
riskKeys := func(state HysteriaServiceState, apiReachable bool) []string {
summary := vo.DashboardSummaryVo{}
summary.Hysteria.ServiceState = string(state)
summary.Hysteria.Running = state == HysteriaServiceActive
summary.Hysteria.ApiReachable = apiReachable
keys := make([]string, 0)
for _, risk := range DashboardSecurityRisks(summary) {
keys = append(keys, risk.Key)
}
return keys
}
if got := riskKeys(HysteriaServiceActive, true); len(got) != 0 {
t.Fatalf("исправная система получила предупреждения: %v", got)
}
stopped := riskKeys(HysteriaServiceInactive, true)
if len(stopped) != 1 || stopped[0] != "dashboard.security.hysteriaStopped" {
t.Fatalf("остановленная служба: %v", stopped)
}
unknown := riskKeys(HysteriaServiceUnknown, true)
if len(unknown) != 1 || unknown[0] != "dashboard.security.hysteriaStateUnknown" {
t.Fatalf("неизвестное состояние выдано за остановленную службу: %v", unknown)
}
both := riskKeys(HysteriaServiceInactive, false)
if len(both) != 2 {
t.Fatalf("два независимых отказа схлопнулись в один: %v", both)
}
}
+193 -21
View File
@@ -4,21 +4,63 @@ import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"hy2xs-admin/model/constant" "math"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"os" "os"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
) )
type journalctlLogLine struct { type journalctlLogLine struct {
Message string `json:"MESSAGE"` Message journalMessage `json:"MESSAGE"`
Priority string `json:"PRIORITY"` Priority string `json:"PRIORITY"`
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"` RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
} }
// journalMessage — поле MESSAGE журнала, которое journald отдаёт ДВУМЯ разными
// способами.
//
// Обычно это строка. Но если сообщение не является корректным UTF-8, `-o json`
// отдаёт его массивом байтовых значений — это документированное поведение
// journald, а не аномалия. Прежний `Message string` на такой записи ронял
// разбор ВСЕЙ строки, и она молча выпадала из журнала: обработчик делал
// `continue`. То есть ровно те строки, ради которых журнал открывают чаще
// всего — с испорченным выводом чужого процесса, — панель не показывала вовсе.
type journalMessage string
func (m *journalMessage) UnmarshalJSON(data []byte) error {
var asString string
if err := json.Unmarshal(data, &asString); err == nil {
*m = journalMessage(asString)
return nil
}
// Массив чисел разбирается через []int, а не []byte: для []byte
// encoding/json ожидает base64-строку и на JSON-массиве откажет.
var asBytes []int
if err := json.Unmarshal(data, &asBytes); err == nil {
raw := make([]byte, 0, len(asBytes))
for _, value := range asBytes {
if value < 0 || value > 255 {
continue
}
raw = append(raw, byte(value))
}
*m = journalMessage(raw)
return nil
}
// Поле отсутствует или имеет неизвестную форму: сообщения нет, но сама
// запись остаётся — у неё есть время и приоритет.
*m = ""
return nil
}
func normalizeJournalLineLimit(value int, fallback int, max int) int { func normalizeJournalLineLimit(value int, fallback int, max int) int {
if value <= 0 { if value <= 0 {
return fallback return fallback
@@ -91,11 +133,39 @@ func ExportHysteriaJournalLogs(numLine int) (string, error) {
return SanitizeLogText(output), nil return SanitizeLogText(output), nil
} }
// Структурный журнал Hysteria разбирается ПО ФАКТИЧЕСКОМУ формату 2.12.2.
//
// Что было. Запись складывалась прямым json.Unmarshal в vo.LogHysteria2Vo, у
// которого `Time string`. JSON-логгер Hysteria объявлен так:
//
// TimeKey: "time", LevelKey: "level", MessageKey: "msg",
// EncodeTime: zapcore.EpochMillisTimeEncoder
//
// то есть `time` — это ЧИСЛО, причём дробное: энкодер делит наносекунды на
// миллисекунду и печатает float64 (`1788321234567.1235`). Разбор падал на
// каждой без исключения строке:
//
// cannot unmarshal number into Go struct field ... of type string
//
// После чего срабатывал fallback и панель показывала сырой JSON целиком. То
// есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а структурой никто не
// пользовался: страница журнала выглядела ровно так же, как без него, только
// менее читаемо.
//
// Замена типа поля на int64 проблему НЕ решает по двум причинам: значение
// дробное, а кроме level/time/msg upstream кладёт в запись диагностический
// контекст (`listen`, `addr`, `id`, `error`, `tx`, `rx`, …), который при
// разборе в три известных поля просто выбрасывался бы. Именно эти поля и
// отвечают на вопрос, ради которого журнал открывают.
//
// Поэтому разбор идёт через map: известные ключи заполняют колонки, остальные
// дописываются к сообщению как `msg [key=value …]` в устойчивом порядке.
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo { func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
fallbackTime := convertJournalTimestamp(item.RealtimeTimestamp) fallbackTime := convertJournalTimestamp(item.RealtimeTimestamp)
fallbackLevel := mapJournalPriorityToLevel(item.Priority) fallbackLevel := mapJournalPriorityToLevel(item.Priority)
message := string(item.Message)
if strings.TrimSpace(item.Message) == "" { if strings.TrimSpace(message) == "" {
return vo.LogHysteria2Vo{ return vo.LogHysteria2Vo{
Level: fallbackLevel, Level: fallbackLevel,
Msg: "", Msg: "",
@@ -103,28 +173,130 @@ func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
} }
} }
parsed := vo.LogHysteria2Vo{} fields := map[string]any{}
if err := json.Unmarshal([]byte(item.Message), &parsed); err == nil { if err := json.Unmarshal([]byte(message), &fields); err != nil {
if parsed.Level == "" { // Не JSON вовсе: журнал Hysteria в console-формате либо посторонняя
parsed.Level = fallbackLevel // строка от рантайма. Показывается как есть.
}
if parsed.Time == "" {
parsed.Time = fallbackTime
}
if parsed.Msg == "" {
parsed.Msg = item.Message
}
parsed.Msg = SanitizeLogText(parsed.Msg)
return parsed
}
return vo.LogHysteria2Vo{ return vo.LogHysteria2Vo{
Level: fallbackLevel, Level: fallbackLevel,
Msg: SanitizeLogText(item.Message), Msg: SanitizeLogText(message),
Time: fallbackTime, Time: fallbackTime,
} }
} }
record := vo.LogHysteria2Vo{
Level: journalStringField(fields, "level"),
Msg: journalStringField(fields, "msg"),
Time: hysteriaLogTime(fields["time"]),
}
if record.Level == "" {
record.Level = fallbackLevel
}
if record.Time == "" {
// Время journald — канонический запасной вариант: оно есть у КАЖДОЙ
// записи и относится к тому же событию.
record.Time = fallbackTime
}
if record.Msg == "" {
// JSON без `msg` — не запись Hysteria. Терять её нельзя.
record.Msg = message
}
record.Msg = SanitizeLogText(record.Msg + hysteriaLogContext(fields))
return record
}
// hysteriaLogReservedFields — ключи, которые уже показаны собственными
// колонками либо не несут смысла для оператора.
//
// `logger` объявлен в EncoderConfig Hysteria (NameKey), но именованных
// логгеров у сервера нет, поэтому в записи он не появляется; ключ перечислен,
// чтобы будущее появление имени не поехало в текст сообщения.
var hysteriaLogReservedFields = map[string]struct{}{
"level": {},
"msg": {},
"time": {},
"logger": {},
}
// hysteriaLogContext собирает структурный контекст записи в устойчивом порядке.
//
// Порядок ключей в map случаен, поэтому без сортировки одна и та же запись
// выглядела бы по-разному при каждом обновлении страницы.
func hysteriaLogContext(fields map[string]any) string {
keys := make([]string, 0, len(fields))
for key := range fields {
if _, reserved := hysteriaLogReservedFields[key]; reserved {
continue
}
keys = append(keys, key)
}
if len(keys) == 0 {
return ""
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", key, journalFieldValue(fields[key])))
}
return " [" + strings.Join(parts, " ") + "]"
}
// journalFieldValue приводит значение поля к однострочному виду.
func journalFieldValue(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case bool:
return strconv.FormatBool(typed)
case float64:
return formatJSONNumber(typed)
default:
// Вложенный объект или массив: JSON компактнее и однозначнее, чем
// проценты Go-форматирования.
encoded, err := json.Marshal(typed)
if err != nil {
return fmt.Sprintf("%v", typed)
}
return string(encoded)
}
}
// formatJSONNumber печатает число без экспоненты и без хвоста `.0`: в журнале
// оператор ожидает `443`, а не `4.43e+02`.
func formatJSONNumber(value float64) string {
if value == math.Trunc(value) && math.Abs(value) < 1e15 {
return strconv.FormatInt(int64(value), 10)
}
return strconv.FormatFloat(value, 'f', -1, 64)
}
func journalStringField(fields map[string]any, key string) string {
if value, ok := fields[key].(string); ok {
return value
}
return ""
}
// hysteriaLogTime переводит `time` записи в отображаемое время.
//
// Число трактуется как epoch millis — так его пишет EpochMillisTimeEncoder.
// Строка принимается как есть: console-формат и будущая смена энкодера дают
// RFC3339, и переписывать его продукт не обязан.
func hysteriaLogTime(value any) string {
switch typed := value.(type) {
case float64:
millis := int64(typed)
return time.UnixMilli(millis).Format("2006-01-02 15:04:05")
case string:
return strings.TrimSpace(typed)
default:
return ""
}
}
func convertJournalTimestamp(raw string) string { func convertJournalTimestamp(raw string) string {
if raw == "" { if raw == "" {
return "" return ""
+271
View File
@@ -0,0 +1,271 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
)
// Разбор журнала проверяется на ФАКТИЧЕСКОМ формате Hysteria 2.12.2.
//
// Записи в тестах не выдуманы: они собираются тем же способом, каким их пишет
// zap с EncoderConfig upstream — `time` числом epoch millis (дробным, потому
// что EpochMillisTimeEncoder делит наносекунды на миллисекунду), `level`
// строчными буквами, `msg` и произвольный структурный контекст рядом.
//
// Прежний разбор падал на КАЖДОЙ такой строке (`cannot unmarshal number into
// Go struct field ... of type string`) и уходил в fallback, показывая оператору
// сырой JSON. То есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а
// структурой никто не пользовался.
// hysteriaJSONLine собирает запись так же, как её пишет zap Hysteria.
func hysteriaJSONLine(t *testing.T, fields map[string]any) string {
t.Helper()
encoded, err := json.Marshal(fields)
if err != nil {
t.Fatalf("не удалось собрать запись журнала: %v", err)
}
return string(encoded)
}
func journalLine(message string) journalctlLogLine {
return journalctlLogLine{
Message: journalMessage(message),
Priority: "6",
RealtimeTimestamp: "1788321234567000",
}
}
// Главная регрессия: числовое `time` больше не роняет разбор.
func TestParseHysteriaJournalRecordReadsNumericEpochMillis(t *testing.T) {
// Значение дробное намеренно: EpochMillisTimeEncoder печатает float64.
moment := time.Date(2026, 9, 2, 12, 34, 56, 789_012_345, time.Local)
millis := float64(moment.UnixNano()) / float64(time.Millisecond)
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": millis,
"level": "info",
"msg": "server up and running",
})))
if record.Level != "info" {
t.Fatalf("уровень %q, ожидался info", record.Level)
}
if record.Msg != "server up and running" {
t.Fatalf("сообщение %q, ожидался разобранный msg", record.Msg)
}
want := moment.Format("2006-01-02 15:04:05")
if record.Time != want {
t.Fatalf("время %q, ожидалось %q", record.Time, want)
}
// Сырой JSON в сообщение не попадает: это и был прежний исход.
if strings.Contains(record.Msg, "{") {
t.Fatalf("в сообщение уехала сырая запись: %q", record.Msg)
}
}
// Структурный контекст upstream сохраняется: именно он отвечает на вопрос,
// ради которого журнал открывают.
func TestParseHysteriaJournalRecordKeepsStructuredContext(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.1235,
"level": "error",
"msg": "authentication failed",
"addr": "203.0.113.10:51820",
"id": "alpha-auth-id",
"error": "device limited",
"listen": ":443",
})))
for _, want := range []string{
"authentication failed",
"addr=203.0.113.10:51820",
"error=device limited",
"id=alpha-auth-id",
"listen=:443",
} {
if !strings.Contains(record.Msg, want) {
t.Fatalf("контекст %q потерян: %q", want, record.Msg)
}
}
}
// Порядок полей устойчив: карта в Go обходится случайно, и без сортировки одна
// и та же запись выглядела бы по-разному при каждом обновлении страницы.
func TestParseHysteriaJournalRecordContextOrderIsStable(t *testing.T) {
line := journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "info",
"msg": "client connected",
"zulu": "z",
"alpha": "a",
"mike": "m",
}))
first := parseHysteriaJournalRecord(line).Msg
for i := 0; i < 20; i++ {
if got := parseHysteriaJournalRecord(line).Msg; got != first {
t.Fatalf("порядок контекста не устойчив: %q против %q", got, first)
}
}
if !strings.Contains(first, "[alpha=a mike=m zulu=z]") {
t.Fatalf("контекст собран не по алфавиту: %q", first)
}
}
// Числа контекста печатаются человеку, а не в экспоненте.
func TestParseHysteriaJournalRecordFormatsNumbers(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "info",
"msg": "traffic",
"tx": float64(1_234_567_890),
"ratio": 0.5,
"ok": true,
})))
for _, want := range []string{"tx=1234567890", "ratio=0.5", "ok=true"} {
if !strings.Contains(record.Msg, want) {
t.Fatalf("значение %q отформатировано неверно: %q", want, record.Msg)
}
}
}
// Вложенный объект остаётся однострочным и читаемым.
func TestParseHysteriaJournalRecordFlattensNestedFields(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "warn",
"msg": "acl reloaded",
"stats": map[string]any{"rules": float64(12)},
})))
if !strings.Contains(record.Msg, `stats={"rules":12}`) {
t.Fatalf("вложенный объект потерян: %q", record.Msg)
}
if strings.Contains(record.Msg, "\n") {
t.Fatalf("запись журнала стала многострочной: %q", record.Msg)
}
}
// Секреты вырезаются и из сообщения, и из структурного контекста: HY2_AUTH_URL
// несёт access_token, и upstream волен упомянуть его в тексте ошибки.
func TestParseHysteriaJournalRecordSanitizesSecrets(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "error",
"msg": "auth backend request failed",
"url": "http://127.0.0.1:8080/internal/hysteria/auth?access_token=super-secret-value",
})))
if strings.Contains(record.Msg, "super-secret-value") {
t.Fatalf("секрет уехал в журнал панели: %q", record.Msg)
}
// Внутри query маркер приезжает percent-кодированным: URL обязан остаться
// валидным URL (см. encodedRedactedPlaceholder в log_sanitize.go).
if !strings.Contains(record.Msg, RedactedPlaceholder) &&
!strings.Contains(record.Msg, encodedRedactedPlaceholder) {
t.Fatalf("секрет вырезан без маркера: %q", record.Msg)
}
// Остальная часть адреса остаётся читаемой: без неё запись перестаёт быть
// диагностикой.
if !strings.Contains(record.Msg, "/internal/hysteria/auth") {
t.Fatalf("адрес вырезан целиком вместо секрета: %q", record.Msg)
}
}
// Не-JSON строка показывается как есть: console-формат и посторонний вывод
// рантайма терять нельзя.
func TestParseHysteriaJournalRecordFallsBackToRawMessage(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine("panic: runtime error: invalid memory address"))
if record.Msg != "panic: runtime error: invalid memory address" {
t.Fatalf("сырое сообщение потеряно: %q", record.Msg)
}
// Время и уровень берутся у journald: у него они есть всегда.
if record.Time == "" {
t.Fatal("время journald не подставлено")
}
if record.Level != "info" {
t.Fatalf("уровень journald не подставлен: %q", record.Level)
}
}
// JSON без `msg` — не запись Hysteria, и терять её тоже нельзя.
func TestParseHysteriaJournalRecordKeepsForeignJSON(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(`{"foo":"bar"}`))
if !strings.Contains(record.Msg, "foo") {
t.Fatalf("посторонний JSON потерян: %q", record.Msg)
}
}
// Запись без собственного времени и уровня добирает их у journald.
func TestParseHysteriaJournalRecordUsesJournaldFallbacks(t *testing.T) {
line := journalLine(hysteriaJSONLine(t, map[string]any{"msg": "no level, no time"}))
line.Priority = "3"
record := parseHysteriaJournalRecord(line)
if record.Level != "error" {
t.Fatalf("уровень не взят из PRIORITY: %q", record.Level)
}
if record.Time != convertJournalTimestamp(line.RealtimeTimestamp) {
t.Fatalf("время не взято из __REALTIME_TIMESTAMP: %q", record.Time)
}
}
// Строковое `time` (console-формат, будущая смена энкодера) принимается как
// есть: переписывать чужой формат продукт не обязан.
func TestParseHysteriaJournalRecordAcceptsStringTime(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": "2026-09-02T12:34:56Z",
"level": "info",
"msg": "server up and running",
})))
if record.Time != "2026-09-02T12:34:56Z" {
t.Fatalf("строковое время подменено: %q", record.Time)
}
}
// MESSAGE, отданный journald массивом байт (сообщение не является корректным
// UTF-8), больше не выбрасывает запись целиком.
func TestJournalMessageAcceptsByteArray(t *testing.T) {
var line journalctlLogLine
// Так journald кодирует «hi\xff» в `-o json`.
raw := `{"MESSAGE":[104,105,255],"PRIORITY":"6","__REALTIME_TIMESTAMP":"1788321234567000"}`
if err := json.Unmarshal([]byte(raw), &line); err != nil {
t.Fatalf("запись с байтовым MESSAGE отвергнута целиком: %v", err)
}
if string(line.Message) != "hi\xff" {
t.Fatalf("байтовое сообщение разобрано неверно: %q", string(line.Message))
}
if line.Priority != "6" {
t.Fatalf("остальные поля записи потеряны: %+v", line)
}
}
func TestJournalMessageAcceptsString(t *testing.T) {
var line journalctlLogLine
if err := json.Unmarshal([]byte(`{"MESSAGE":"plain","PRIORITY":"4"}`), &line); err != nil {
t.Fatalf("обычная запись отвергнута: %v", err)
}
if string(line.Message) != "plain" {
t.Fatalf("сообщение разобрано неверно: %q", string(line.Message))
}
}
// Отсутствующее или неизвестной формы MESSAGE не роняет запись: у неё остаются
// время и приоритет.
func TestJournalMessageSurvivesUnknownShape(t *testing.T) {
var line journalctlLogLine
if err := json.Unmarshal([]byte(`{"MESSAGE":{"unexpected":true},"PRIORITY":"6"}`), &line); err != nil {
t.Fatalf("запись с неизвестной формой MESSAGE отвергнута: %v", err)
}
if string(line.Message) != "" {
t.Fatalf("неизвестная форма превратилась в сообщение: %q", string(line.Message))
}
}
+21 -3
View File
@@ -70,6 +70,24 @@ func GenToken(accountBo bo.AccountBo) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
} }
// Отказ разбора токена — это ЗНАЧЕНИЕ, а не свежая ошибка с текстом внутри.
//
// Различие существенно для вызывающего: middleware обязан отличить истёкшую
// сессию от недействительного токена, потому что оператору это показывается
// по-разному — «сессия истекла, войдите заново» против «войдите». Раньше
// единственным способом задать этот вопрос было сравнение err.Error() с
// константой, то есть разбор человеческого текста; такое сравнение молча
// перестаёт работать при первой же правке формулировки.
//
// Тексты сохранены прежними: они уезжают в ответ панели.
var (
// ErrTokenExpired — токен разобран, но его срок истёк.
ErrTokenExpired = errors.New(constant.TokenExpiredError)
// ErrTokenInvalid — токен не разобран, подписан не тем ключом или не
// содержит ожидаемых утверждений.
ErrTokenInvalid = errors.New(constant.IllegalTokenError)
)
func ParseToken(tokenString string) (*MyClaims, error) { func ParseToken(tokenString string) (*MyClaims, error) {
secret, err := jwtSecret() secret, err := jwtSecret()
if err != nil { if err != nil {
@@ -89,14 +107,14 @@ func ParseToken(tokenString string) (*MyClaims, error) {
) )
if err != nil { if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) { if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New(constant.TokenExpiredError) return nil, ErrTokenExpired
} }
return nil, errors.New(constant.IllegalTokenError) return nil, ErrTokenInvalid
} }
claims, ok := token.Claims.(*MyClaims) claims, ok := token.Claims.(*MyClaims)
if !ok || !token.Valid { if !ok || !token.Valid {
return nil, errors.New(constant.IllegalTokenError) return nil, ErrTokenInvalid
} }
return claims, nil return claims, nil
} }
+9 -2
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -119,8 +120,14 @@ func TestParseTokenRejectsExpiredToken(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("истёкший токен принят") t.Fatal("истёкший токен принят")
} }
if err.Error() != constant.TokenExpiredError { // Вопрос задаётся значению, а не тексту: middleware различает истёкшую
t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err) // сессию и недействительный токен именно через errors.Is, и проверка здесь
// обязана закреплять тот же способ.
if !errors.Is(err, ErrTokenExpired) {
t.Errorf("истечение срока должно сообщаться отдельным значением, получено: %v", err)
}
if errors.Is(err, ErrTokenInvalid) {
t.Error("истёкший токен не должен выглядеть как недействительный")
} }
} }
+10 -1
View File
@@ -104,7 +104,16 @@ func CollectMetricsSnapshot() {
s.System.DiskTotalBytes = diskInfo.Total s.System.DiskTotalBytes = diskInfo.Total
} }
s.Hysteria.Running = Hysteria2IsRunning() // Два независимых источника, два независимых вопроса.
//
// Раньше второй выводился из первого: общий Hysteria2Online отдавал пустую
// карту БЕЗ ошибки, когда systemctl говорил «служба неактивна», и сюда
// приходило `err == nil` — то есть `ApiReachable = true` выставлялся, ни
// разу не сходив в Traffic Stats API. Дашборд утверждал «Hysteria
// остановлена» и «API доступен, онлайн 0» одновременно.
serviceState := Hysteria2ServiceState()
s.Hysteria.ServiceState = string(serviceState)
s.Hysteria.Running = serviceState == HysteriaServiceActive
s.Hysteria.Version = getCachedHysteriaVersion(time.Now()) s.Hysteria.Version = getCachedHysteriaVersion(time.Now())
if onlineMap, err := Hysteria2Online(); err == nil { if onlineMap, err := Hysteria2Online(); err == nil {
+383 -51
View File
@@ -4,6 +4,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
@@ -11,15 +14,33 @@ import (
"hy2xs-admin/model/dto" "hy2xs-admin/model/dto"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"hy2xs-admin/util"
) )
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) { // PagePeer отдаёт страницу списка пиров и ЧЕСТНО сообщает, известна ли картина
// подключений.
//
// Что было: `onlineUsers, _ := Hysteria2Online()`. Отказ Traffic Stats API
// молча превращался в пустую карту, и оператор видел «все офлайн» вместо
// «состояние неизвестно». Признак возвращается один на страницу — см.
// vo.PeerPageVo.
func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, string, error) {
peers, total, err := dao.PagePeer(peerPageDto) peers, total, err := dao.PagePeer(peerPageDto)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, "", err
} }
onlineUsers, _ := Hysteria2Online()
onlineState := vo.PeerOnlineStateOk
onlineUsers, onlineErr := Hysteria2Online()
if onlineErr != nil {
// Отказ здесь не является отказом страницы: список пиров и их
// сохранённое состояние известны и полезны сами по себе. Неизвестной
// становится ровно одна колонка, и об этом сказано явно.
logrus.WithError(onlineErr).
Warn("список пиров: картина подключений недоступна, признак online не определён")
onlineState = vo.PeerOnlineStateUnavailable
onlineUsers = map[string]int64{}
}
result := make([]vo.PeerVo, 0, len(peers)) result := make([]vo.PeerVo, 0, len(peers))
for _, p := range peers { for _, p := range peers {
item := vo.PeerVo{ item := vo.PeerVo{
@@ -43,7 +64,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
} }
result = append(result, item) result = append(result, item)
} }
return result, total, nil return result, total, onlineState, nil
} }
// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте. // Пир установщика защищён во ВСЕХ путях записи, а не только в импорте.
@@ -63,41 +84,38 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
// результат которых виден в списке пиров. Bootstrap-пир после установки // результат которых виден в списке пиров. Bootstrap-пир после установки
// остаётся действующим доступом, и запрет его убрать означал бы вечный // остаётся действующим доступом, и запрет его убрать означал бы вечный
// неотзываемый доступ. // неотзываемый доступ.
var errBootstrapPeerIdentity = fmt.Errorf( var errBootstrapPeerIdentity = &PeerError{
Code: constant.ErrCodePeerBootstrapLocked,
Message: fmt.Sprintf(
"пир %q принадлежит установщику: его имя и секрет продублированы в "+ "пир %q принадлежит установщику: его имя и секрет продублированы в "+
"/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+ "/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+
"Ненужный bootstrap-пир следует удалить целиком, а не переподписывать", "Ненужный bootstrap-пир следует удалить целиком, а не переподписывать",
ReservedBootstrapPeerName, ReservedBootstrapPeerName,
) ),
}
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if peerDto.Name == nil || *peerDto.Name == "" { if peerDto.Name == nil || *peerDto.Name == "" {
return vo.PeerVo{}, errors.New(constant.InvalidError) return vo.PeerVo{}, ErrPeerNameRequired
} }
// Имя зарезервировано за установщиком даже когда сам пир уже удалён: // Имя зарезервировано за установщиком даже когда сам пир уже удалён:
// иначе после удаления обычный пир мог бы занять это имя и оказаться под // иначе после удаления обычный пир мог бы занять это имя и оказаться под
// защитой, предназначенной не ему. // защитой, предназначенной не ему.
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName) return vo.PeerVo{}, ErrPeerNameReserved
} }
taken, err := ExistPeerName(*peerDto.Name, 0) taken, err := ExistPeerName(*peerDto.Name, 0)
if err != nil { if err != nil {
return vo.PeerVo{}, err return vo.PeerVo{}, err
} }
if taken { if taken {
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name) return vo.PeerVo{}, PeerNameTakenError(*peerDto.Name)
} }
secret := "" secret, err := resolvePeerSecret(*peerDto.Name, peerDto.Secret)
if peerDto.Secret != nil && *peerDto.Secret != "" {
secret = *peerDto.Secret
} else {
generated, err := util.RandomString(24)
if err != nil { if err != nil {
return vo.PeerVo{}, err return vo.PeerVo{}, err
} }
secret = fmt.Sprintf("%s.%s", *peerDto.Name, generated) authId, err := newPeerAuthID()
}
authId, err := util.RandomString(18)
if err != nil { if err != nil {
return vo.PeerVo{}, err return vo.PeerVo{}, err
} }
@@ -128,7 +146,16 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
} }
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error { func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil { // Снимок «до» читается ОДИН раз и обслуживает обе задачи: защиту пира
// установщика и решение о том, нужен ли разрыв живой сессии. Раньше
// assertBootstrapPeerIdentityUnchanged читал одного и того же пира до двух
// раз подряд собственными запросами.
before, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if err := assertBootstrapPeerIdentityUnchanged(before, peerDto); err != nil {
return err return err
} }
@@ -136,17 +163,33 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if peerDto.Name != nil && *peerDto.Name != "" { if peerDto.Name != nil && *peerDto.Name != "" {
updates["name"] = *peerDto.Name updates["name"] = *peerDto.Name
} }
if peerDto.Secret != nil && *peerDto.Secret != "" { if secret, requested := requestedSecret(peerDto.Secret); requested {
digest, err := PeerSecretDigest(*peerDto.Secret) digest, err := PeerSecretDigest(secret)
if err != nil { if err != nil {
return err return err
} }
enc, err := EncryptPeerSecret(*peerDto.Secret) enc, err := EncryptPeerSecret(secret)
if err != nil { if err != nil {
return err return err
} }
updates["secret_digest"] = digest updates["secret_digest"] = digest
updates["secret_ciphertext"] = enc updates["secret_ciphertext"] = enc
// Новое поколение учётных данных получает новую идентичность живых
// сессий. Без этого сессия, установленная по уже отозванному секрету,
// оставалась неотличимой от законной: `auth_id` у неё прежний, пир в
// базе есть, доступ открыт — и cron не имел ни одного признака, по
// которому её следовало бы завершить. Разбор — в peer_secret.go.
//
// Разрыв при этом идёт по СТАРОМУ значению (см. ниже): именно им
// Hysteria знает сессию, которую отзывает оператор.
if credentialGenerationChanged(before.SecretDigest, digest) {
rotatedAuthID, err := newPeerAuthID()
if err != nil {
return err
}
updates["auth_id"] = rotatedAuthID
}
} }
if peerDto.QuotaBytes != nil { if peerDto.QuotaBytes != nil {
updates["quota_bytes"] = *peerDto.QuotaBytes updates["quota_bytes"] = *peerDto.QuotaBytes
@@ -163,30 +206,159 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
if peerDto.Remark != nil { if peerDto.Remark != nil {
updates["remark"] = *peerDto.Remark updates["remark"] = *peerDto.Remark
} }
return dao.UpdatePeer([]int64{id}, updates)
// Решение принимается ДО записи, а сам разрыв — строго ПОСЛЕ неё.
//
// Порядок обязателен и обратному не подлежит: сначала долговременная
// запись, потом разрыв. При обратном порядке клиент успевает
// переподключиться в окне между `/kick` и записью — и остаётся на связи с
// пиром, чьё состояние уже изменено.
needsReconcile := updateRequiresReconcile(before, peerDto, time.Now().UnixMilli())
if err := dao.UpdatePeer([]int64{id}, updates); err != nil {
return err
}
if !needsReconcile {
return nil
}
// Ровно ОДИН `/kick` при любом сочетании изменений: ротация секрета вместе
// с отключением и урезанной квотой — это по-прежнему одна операция над
// одним пиром.
return reconcileLiveSessions([]string{authIDOf(before)})
}
// updateRequiresReconcile отвечает, делает ли правка живую сессию устаревшей.
//
// Что было. Разрыв выполнялся при одном-единственном условии:
//
// peerDto.Disabled != nil && *peerDto.Disabled == 1
//
// Отключение пира действительно было первым, что починили, но оно не
// единственный способ отозвать доступ через форму. Мимо проверки проходили:
//
// смена секрета — старые учётные данные недействительны, а сессия,
// установленная по ним, продолжает работать;
// урезание квоты — «100 ГБ -> 5 ГБ» при израсходованных 10 ГБ;
// перенос срока — «истекает завтра» -> «истёк вчера»;
// снижение лимита — «5 устройств -> 1» при пяти подключённых.
//
// Во всех четырёх случаях панель показывала новое состояние, а пир продолжал
// пользоваться доступом по старому — то есть ровно тот же дефект, что и в
// UX-06, только под другими именами полей.
//
// Правило асимметрично намеренно: ограничение применяется немедленно,
// послабление — нет. Увеличенная квота, продлённый срок, поднятый лимит
// устройств и правка имени или пометки сессию не рвут: у оператора нет
// причины ронять работающее соединение, расширяя пиру права.
func updateRequiresReconcile(before entity.Peer, peerDto dto.PeerUpdateDto, now int64) bool {
// Смена секрета. Учётные данные, по которым сессия была установлена, с
// этого момента недействительны — держать её открытой нечем.
//
// Условие смотрит на ЗАПРОС, а не на смену поколения credentials: повтор
// того же секрета после неудавшегося разрыва обязан дать вторую попытку,
// ровно как повторное «Отключить». Ротация `auth_id` при этом не
// выполняется — см. credentialGenerationChanged.
if _, requested := requestedSecret(peerDto.Secret); requested {
return true
}
// Запрошенное `disabled=1`, а не переход из включённого состояния.
//
// Так операция остаётся повторяемой: если разрыв не удался, оператор
// повторяет «Отключить» и получает вторую попытку, вместо того чтобы
// включать пира ради возможности отключить его снова.
if peerDto.Disabled != nil && *peerDto.Disabled == 1 {
return true
}
// Снижение лимита устройств.
//
// Выбирать «лишнее устройство» не нужно и невозможно: `/kick` оперирует
// идентификатором клиента, а не конкретным экземпляром подключения. После
// переподключения новый admission limit пропустит ровно столько
// устройств, сколько разрешено теперь.
if peerDto.MaxDevices != nil && before.MaxDevices != nil &&
*peerDto.MaxDevices < *before.MaxDevices {
return true
}
// Квота и срок: разрыв нужен, только если значение ДЕЙСТВИТЕЛЬНО менялось
// и новое значение уже закрывает доступ. Проверка идёт через ту же
// peerAccessDenied, что и авторизация, поэтому «закрывает доступ» здесь и
// «не пустит при следующем подключении» — буквально одно условие.
after := before
changed := false
if peerDto.QuotaBytes != nil && (before.QuotaBytes == nil || *peerDto.QuotaBytes != *before.QuotaBytes) {
quota := *peerDto.QuotaBytes
after.QuotaBytes = &quota
changed = true
}
if peerDto.ExpiresAt != nil && (before.ExpiresAt == nil || *peerDto.ExpiresAt != *before.ExpiresAt) {
expires := *peerDto.ExpiresAt
after.ExpiresAt = &expires
changed = true
}
if !changed {
return false
}
// Пир, которому доступ был закрыт и до правки, отдельного разрыва не
// требует: его сессию уже завершил тот, кто закрыл доступ.
return peerAccessDenied(after, now) && !peerAccessDenied(before, now)
}
// reconcileLiveSessions приводит живые сессии Hysteria в соответствие с уже
// СОХРАНЁННЫМ состоянием.
//
// Единственный путь для всех операций, способных сделать живую сессию
// устаревшей: отключение пира, временная блокировка, смена секрета, урезание
// квоты и срока, снижение лимита устройств, импорт партии и удаление пира.
// Отдельных методов kick для каждой из них нет намеренно — иначе «применили
// изменение, но забыли завершить сессию» появлялось бы заново с каждой новой
// операцией, ровно так, как это уже случилось с удалением и импортом.
//
// Вызывается СТРОГО после того, как долговременное состояние записано.
// Обратный порядок оставляет клиенту окно между `/kick` и записью, в котором
// он успевает переподключиться и остаётся на связи с уже изменённым пиром.
//
// Отказ НЕ откатывает состояние: безопасная половина операции достигнута, и
// возвращать доступ из-за неудачи второго шага нельзя. Вызывающему
// возвращается частичный результат отдельным кодом — см. PeerDisconnectError.
func reconcileLiveSessions(authIDs []string) error {
if err := disconnectAuthIDs(authIDs); err != nil {
logrus.WithError(err).
WithField("authIds", len(authIDs)).
Error("peer state persisted, but hysteria2 session disconnect failed")
return PeerDisconnectError()
}
return nil
}
// authIDOf возвращает идентификатор, которым Hysteria знает пира, или пустую
// строку. Пира без authId Hysteria не знает: рвать нечего, и это не отказ.
func authIDOf(peer entity.Peer) string {
if peer.AuthId == nil {
return ""
}
return *peer.AuthId
} }
// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано // assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано
// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя. // в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя.
func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error { //
// Работает по УЖЕ ПРОЧИТАННОМУ снимку пира: раньше функция делала до двух
// собственных запросов за ту же строку, которую вызывающий читает и сам.
func assertBootstrapPeerIdentityUnchanged(existing entity.Peer, peerDto dto.PeerUpdateDto) error {
isBootstrap := existing.Name != nil && *existing.Name == ReservedBootstrapPeerName
// Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от // Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от
// проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир // проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир
// существует. // существует.
if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName && !isBootstrap {
existing, err := dao.GetPeer("id = ?", id) return ErrPeerNameReserved
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
} }
existing, err := dao.GetPeer("id = ?", id) if !isBootstrap {
if err != nil {
return err
}
if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName {
return nil return nil
} }
@@ -218,8 +390,55 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
// //
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом // Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже // владеет оркестратор, админка его не трогает; после отзыва он содержит уже
// недействующее значение (см. docs/04-admin-panel.md). // недействующее значение (см. docs/admin/04-admin-panel.md).
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } //
// Порядок шагов — не стилистика, а единственный, который не оставляет пиру
// доступ.
//
// Что было: `return dao.DeletePeer([]int64{id})`. Строка исчезала, живая
// QUIC-сессия оставалась, и — хуже того — вместе со строкой исчезал `auth_id`,
// то есть единственное, чем эту сессию можно было завершить. Состояние
// становилось невосстановимым: удалённый пир пользовался доступом, пока не
// переподключался по своей воле, и сделать с этим было уже нечего.
//
// Теперь:
//
// 1. прочитать пира и запомнить его auth ID;
// 2. записать disabled=1 — закрыть будущие обращения к HTTP-auth;
// 3. завершить живые сессии по запомненному auth ID;
// 4. удалить строку.
//
// Ключевые исходы:
//
// запись не удалась -> строка не изменена, удаления не было;
// разрыв не удался -> строка остаётся с disabled=1, новые подключения
// запрещены, оператор повторяет удаление;
// разрыв прошёл, а
// удаление не удалось -> строка остаётся отключённой, сессия уже завершена.
//
// Ни один из них не возвращает пиру доступ, и отката после `/kick` нет
// намеренно: снимать достигнутое безопасное состояние из-за неудачи
// последнего шага нельзя.
//
// Запись идёт через dao, а не через сервисный UpdatePeer: guard пира
// установщика запрещает менять его ИДЕНТИЧНОСТЬ, а не отключать его, и
// пропускать законное удаление через проверку смены имени и секрета незачем.
func DeletePeer(id int64) error {
peer, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"disabled": int64(1)}); err != nil {
return err
}
if err := reconcileLiveSessions([]string{authIDOf(peer)}); err != nil {
return err
}
return dao.DeletePeer([]int64{id})
}
func GetPeerVo(id int64) (vo.PeerVo, error) { func GetPeerVo(id int64) (vo.PeerVo, error) {
p, err := dao.GetPeer("id = ?", id) p, err := dao.GetPeer("id = ?", id)
@@ -249,11 +468,30 @@ func ReleaseKickPeer(id int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0}) return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
} }
// KickPeer — временная блокировка: пир не пускается до bannedUntil, а его
// текущая сессия завершается.
//
// Механизм независим от `disabled`. Это два разных решения оператора с разным
// сроком жизни: `banned_until` истекает сам, `disabled` снимается только
// руками. Поэтому ReleaseKickPeer не трогает `disabled`, а включение пира не
// сбрасывает `banned_until`.
//
// Что было. `banned_until` записывался здесь, а затем ВТОРОЙ РАЗ внутри
// Hysteria2Kick — то же значение двумя операторами подряд. Хуже дублирования
// был порядок отказа: Hysteria2Kick начинался с проверки состояния службы и
// возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже
// применилась. Операция отвечала чистым отказом, находясь при этом в
// применённом состоянии, и оператор видел «не сработало» у сработавшей
// блокировки.
func KickPeer(id int64, bannedUntil int64) error { func KickPeer(id int64, bannedUntil int64) error {
peer, err := dao.GetPeer("id = ?", id)
if err != nil {
return err
}
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil { if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil {
return err return err
} }
return Hysteria2Kick([]int64{id}, bannedUntil) return reconcileLiveSessions([]string{authIDOf(peer)})
} }
func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) { func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
@@ -428,11 +666,12 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
entry.createDigest = digest entry.createDigest = digest
entry.createCipher = cipher entry.createCipher = cipher
} else { } else {
generated, err := util.RandomString(24) // Генерация одна на весь продукт: импорт без секрета обязан давать
// пира, неотличимого от созданного через форму.
createSecret, err := GeneratePeerSecret(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
createSecret := fmt.Sprintf("%s.%s", name, generated)
digest, err := PeerSecretDigest(createSecret) digest, err := PeerSecretDigest(createSecret)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -447,7 +686,7 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
entry.createAuthID = authID entry.createAuthID = authID
if entry.createAuthID == "" { if entry.createAuthID == "" {
generated, err := util.RandomString(18) generated, err := newPeerAuthID()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -474,6 +713,39 @@ func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
// Конфликт не гипотетический: пусть в базе есть A(auth_id=a, name=alice) и // Конфликт не гипотетический: пусть в базе есть A(auth_id=a, name=alice) и
// B(auth_id=b, name=bob), а файл несёт (auth_id=a, name=bob). Поиск найдёт A // B(auth_id=b, name=bob), а файл несёт (auth_id=a, name=bob). Поиск найдёт A
// по auth_id и переименует его в bob — прямо в UNIQUE(name). // по auth_id и переименует его в bob — прямо в UNIQUE(name).
//
// Четвёртый проход появился позже трёх: живые сессии.
//
// Импорт — это bulk state replacement, а не правка пометки: он переписывает
// credential- и access-состояние существующего пира целиком, включая
// `auth_id`, `secret_digest`, `quota_bytes`, `expires_at` и `disabled`. Сессии
// при этом не трогались вовсе, поэтому пир, отключённый импортом или
// получивший новый секрет, продолжал пользоваться доступом по старому.
//
// Старые auth ID собираются ВНУТРИ транзакции и разрываются ПОСЛЕ commit.
// Оба слова важны:
//
// внутри — потому что после commit старого значения в базе уже нет:
// `auth_id` перезаписан значением из файла;
// после — потому что `/kick` до commit оставляет клиенту окно, в котором он
// переподключается к ещё не изменённому пиру.
//
// Разрываются сессии ВСЕХ существующих записей партии, а не тех, у кого
// изменилось конкретное поле. Это сознательно более простой контракт, чем diff
// по семи access-полям:
//
// 1. импорт и так переписывает состояние целиком;
// 2. старый auth ID гарантированно нужен при его замене;
// 3. повтор того же импорта после неудавшегося `/kick` обязан снова
// попытаться завершить старые сессии;
// 4. не появляется ещё одной таблицы правил «какие поля импорта считаются
// access-changing» — то есть второго места, где политика может разойтись
// с peerAccessDenied.
//
// Цена — существующие пиры из импортируемой партии один раз переподключаются.
// Для административной операции переноса это нормальная цена.
//
// Новые пиры не разрываются: до импорта их живых сессий существовать не могло.
func UpsertPeerExport(items []bo.PeerExport) error { func UpsertPeerExport(items []bo.PeerExport) error {
if err := ValidatePeerImportBatch(items); err != nil { if err := ValidatePeerImportBatch(items); err != nil {
return err return err
@@ -484,26 +756,42 @@ func UpsertPeerExport(items []bo.PeerExport) error {
return err return err
} }
return dao.WithPeerTx(func(tx dao.PeerTx) error { var replacedAuthIDs []string
if err := dao.WithPeerTx(func(tx dao.PeerTx) error {
// Список собирается заново на каждой попытке: WithPeerTx может
// вызвать функцию повторно, и накопленный от прошлого прохода хвост
// означал бы разрыв сессий, которых партия не касалась.
replacedAuthIDs = replacedAuthIDs[:0]
for _, entry := range prepared { for _, entry := range prepared {
if err := applyPeerImportEntry(tx, entry); err != nil { replaced, err := applyPeerImportEntry(tx, entry)
if err != nil {
return err return err
} }
if replaced != "" {
replacedAuthIDs = append(replacedAuthIDs, replaced)
}
} }
return nil return nil
}) }); err != nil {
// Транзакция откачена: состояние не менялось, разрывать нечего.
return err
} }
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error { return reconcileLiveSessions(replacedAuthIDs)
}
// applyPeerImportEntry применяет одну запись и возвращает auth ID, который
// Hysteria знала ДО применения, — пустую строку для вновь созданной записи.
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) (string, error) {
existing, found, err := findPeerForImport(tx, entry) existing, found, err := findPeerForImport(tx, entry)
if err != nil { if err != nil {
return err return "", err
} }
// Пир установщика не переопределяется импортом ни при каком совпадении: // Пир установщика не переопределяется импортом ни при каком совпадении:
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret. // его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName { if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
return fmt.Errorf( return "", fmt.Errorf(
"peer import: пир %q принадлежит установщику и не может быть изменён импортом", "peer import: пир %q принадлежит установщику и не может быть изменён импортом",
ReservedBootstrapPeerName, ReservedBootstrapPeerName,
) )
@@ -529,7 +817,38 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
updates["secret_digest"] = entry.explicitDigest updates["secret_digest"] = entry.explicitDigest
updates["secret_ciphertext"] = entry.explicitCipher updates["secret_ciphertext"] = entry.explicitCipher
} }
return tx.UpdatePeer([]int64{*existing.Id}, updates) // Значение читается ДО записи: после неё в строке уже стоит auth ID из
// файла, а Hysteria знает пира по прежнему.
replaced := authIDOf(existing)
// Импорт — вторая дверь к смене учётных данных, и правило поколения у
// неё то же самое: новый секрет получает новую идентичность сессий.
//
// Дверь эта закрывалась отдельно, потому что здесь состояние приходит
// из файла. Случай, который проходил мимо: запись найдена по ИМЕНИ либо
// несёт прежний `auth_id`, а секрет в файле новый. Тогда `auth_id`
// оставался прежним, и сессия, установленная по отозванному секрету,
// снова выглядела законной — тот же дефект, что и в UpdatePeer.
//
// Значение `auth_id` из файла при этом перебивается, и это осознанно:
// в импорте оно является КЛЮЧОМ ПОИСКА и переносимым состоянием, а не
// обещанием сохранить идентичность отозванного поколения. Если файл
// несёт ДРУГОЙ `auth_id`, ротация не нужна — идентичность уже сменилась
// им самим, и старая сессия становится orphan без нашего участия.
if entry.hasExplicitSecret &&
credentialGenerationChanged(existing.SecretDigest, entry.explicitDigest) &&
resultingAuthID(entry, replaced) == replaced {
rotated, err := newPeerAuthID()
if err != nil {
return "", err
}
updates["auth_id"] = rotated
}
if err := tx.UpdatePeer([]int64{*existing.Id}, updates); err != nil {
return "", err
}
return replaced, nil
} }
name := entry.name name := entry.name
@@ -561,8 +880,21 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
BannedUntil: &bannedUntil, BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection, LastConnectionAt: &lastConnection,
} }
// Вновь созданная запись: живой сессии до импорта существовать не могло.
_, saveErr := tx.SavePeer(peer) _, saveErr := tx.SavePeer(peer)
return saveErr return "", saveErr
}
// resultingAuthID — значение, которое окажется в строке после применения
// записи импорта, если ротации не будет.
//
// Файл волен не нести `auth_id` вовсе: тогда поиск шёл по имени, и прежнее
// значение остаётся.
func resultingAuthID(entry preparedPeerImport, existing string) string {
if entry.authID != "" {
return entry.authID
}
return existing
} }
// findPeerForImport ищет запись, которую импорт должен обновить. // findPeerForImport ищет запись, которую импорт должен обновить.
+172
View File
@@ -0,0 +1,172 @@
package service
import (
"hy2xs-admin/model/entity"
)
// Правило доступа пира объявлено ОДИН РАЗ и живёт в Go, а не в SQL.
//
// Что было. Правило существовало в двух экземплярах, написанных разными
// условиями в разных местах.
//
// Авторизация (Hysteria2Auth) прятала его в выборке:
//
// disabled = 0
// and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes)
// and (expires_at = 0 or ? < expires_at)
// and ? > banned_until
//
// Принудительное отключение (cron) — в своей выборке, уже с другими границами:
//
// disabled = 1
// or (quota_bytes > 0 and quota_bytes < download_bytes + upload_bytes)
// or (expires_at > 0 and ? > expires_at)
// or ? < banned_until
//
// Это не стилистическое дублирование. Второе условие — не отрицание первого, и
// расхождение приходилось ровно на границы:
//
// quota = 0 auth отказывает, cron сессию не рвёт
// usage = quota auth отказывает, cron сессию не рвёт
// now = expiresAt auth отказывает, cron сессию не рвёт
// now = bannedUntil auth отказывает, cron сессию не рвёт
//
// Хуже всего вела себя исчерпанная квота. `quota_bytes < download + upload`
// требует СТРОГОГО превышения, а счётчики растут порциями по ответу Traffic
// Stats API, поэтому попадание в точное равенство — не экзотика, а обычный
// исход последнего сбора. Пир с исчерпанной квотой не пускался заново, но его
// живая сессия не разрывалась НИКОГДА: он продолжал пользоваться доступом,
// пока не переподключался по своей воле.
//
// Поэтому политика перестаёт быть частью запроса и становится функцией
// продукта. Авторизация и enforcement физически не могут разойтись, потому что
// спрашивают одно и то же.
//
// Производительность здесь не страдает: авторизация всё равно ищет ОДНУ строку
// по secret_digest, а cron всё равно читает пиров, которых Hysteria назвала
// онлайн.
// peerAccessDenied отвечает на единственный вопрос: закрыт ли доступ пиру
// прямо сейчас.
//
// disabled == 1 -> DENY
// quotaBytes < 0 -> квота не ограничена
// quotaBytes >= 0 && usage >= quotaBytes -> DENY
// expiresAt > 0 && now >= expiresAt -> DENY
// bannedUntil > now -> DENY
// иначе -> ALLOW
//
// Границы выбраны по смыслу самих названий:
//
// quota = -1 единственный способ сказать «без ограничения»;
// quota = 0 нулевая квота — это ноль байтов, а не безлимит;
// usage = quota выданный лимит уже израсходован целиком;
// expiresAt = now срок доступа уже наступил, то есть истёк;
// bannedUntil = now временная блокировка уже закончилась.
//
// Отрицательная квота любой величины означает «без ограничения» — так же, как
// это делала выборка авторизации (`quota_bytes < 0`). Через двери продукта
// значение меньше -1 недостижимо: и dto.PeerSaveDto, и валидация импорта
// требуют `>= -1`. Канон один — `-1`.
func peerAccessDenied(peer entity.Peer, now int64) bool {
// Строка без решающего поля — повреждённые данные, а не пир без
// ограничений.
//
// Все эти колонки объявлены NOT NULL с DEFAULT, поэтому nil здесь может
// означать только повреждение. На пути принятия решения о доступе такая
// строка обязана вести к отказу: молчаливое «поле не задано, значит можно»
// — это ровно тот способ, которым ограничение перестаёт быть ограничением.
if peer.Disabled == nil || peer.QuotaBytes == nil ||
peer.DownloadBytes == nil || peer.UploadBytes == nil ||
peer.ExpiresAt == nil || peer.BannedUntil == nil {
return true
}
if *peer.Disabled == 1 {
return true
}
if *peer.QuotaBytes >= 0 && *peer.DownloadBytes+*peer.UploadBytes >= *peer.QuotaBytes {
return true
}
if *peer.ExpiresAt > 0 && now >= *peer.ExpiresAt {
return true
}
if *peer.BannedUntil > now {
return true
}
return false
}
// Живая сессия сверяется с сохранённым состоянием ПОВТОРЯЕМО.
//
// Что было. Приведение сессий к состоянию базы выполнялось ровно один раз — в
// той же операции, которая это состояние записала. Порядок «сначала запись,
// потом `/kick`» правильный, и откат при неудаче разрыва делать нельзя: часть
// операции, закрывающая доступ, уже достигнута. Но второй попытки после
// неудачи не существовало вовсе, и два состояния оставались навсегда.
//
// Первое — замена `auth_id` импортом:
//
// импорт old-auth -> new-auth, COMMIT прошёл
// /kick old-auth -> 500
// оператор повторяет тот же импорт
// applyPeerImportEntry читает из базы уже new-auth и рвёт ЕГО
//
// Старый идентификатор после первой же неудачи не хранился нигде, а cron его
// пропускал: `dao.ListPeer("auth_id in ?")` просто не возвращала строку, и
// authID, которого нет в базе, молча выпадал из обхода. Живая QUIC-сессия
// удалённого или переподписанного пира продолжалась сколько угодно долго.
//
// Второе — снижение `maxDevices`:
//
// 5 -> 1, запись прошла, /kick -> 500
// оператор повторяет сохранение формы
// updateRequiresReconcile сравнивает 1 < 1 -> false, разрыва нет
//
// Для `disabled` повторяемость сделана специально (условие смотрит на
// ЗАПРОШЕННОЕ состояние, а не на переход), для квоты и срока её обеспечивает
// cron через peerAccessDenied. Лимит устройств в политику доступа не входит и
// входить не должен — это свойство не пира, а его сессий, — поэтому здесь у
// него не было ни одного механизма схождения.
//
// Оба состояния закрывает один и тот же приём: cron сверяет не «кого из
// известных пиров пора отключить», а КАЖДЫЙ authID, который Hysteria считает
// живым. Отдельная таблица retry, очередь отложенных операций и хранимый
// «список того, что не удалось разорвать» для этого не нужны: `/online` и есть
// список живых сессий, и сверять его достаточно.
// peerSessionNeedsReconcile отвечает, устарела ли живая сессия пира.
//
// Вторым экземпляром политики доступа не является: disabled, quota, expiry и
// ban остаются целиком за peerAccessDenied, и эта функция их не повторяет, а
// вызывает. Своего здесь ровно одно — инвариант живых сессий, которого в
// хранимом состоянии пира нет: число подключённых устройств.
//
// доступ закрыт -> сессия устарела
// maxDevices непригоден -> сессия устарела
// устройств больше, чем разрешено -> сессии устарели
//
// Непригодный `maxDevices` ведёт к разрыву по той же причине, по которой он
// ведёт к отказу в авторизации: повреждённая граница — это не «безлимит».
//
// Число устройств берётся из `/online`, который по официальному контракту
// Traffic Stats API возвращает количество экземпляров клиента Hysteria
// («устройства»), а не число proxy-потоков. То есть сравнение с `maxDevices`
// здесь опирается на upstream-контракт, а не на предположение.
//
// Выбирать «лишнее устройство» не нужно и невозможно: `/kick` оперирует
// идентификатором клиента. После разрыва клиенты переподключаются, и
// admission пропустит ровно столько, сколько разрешено теперь.
func peerSessionNeedsReconcile(peer entity.Peer, onlineDevices int64, now int64) bool {
if peerAccessDenied(peer, now) {
return true
}
if peer.MaxDevices == nil || *peer.MaxDevices < 1 {
return true
}
return onlineDevices > *peer.MaxDevices
}
+200
View File
@@ -0,0 +1,200 @@
package service
import (
"testing"
"hy2xs-admin/model/entity"
)
// Границы политики доступа проверяются ТОЧЕЧНО, а не «примерно».
//
// Именно на границах два прежних экземпляра правила и расходились: авторизация
// не пускала пира при `usage == quota`, а принудительное отключение требовало
// строгого превышения и сессию не рвало. Пир с исчерпанной квотой не мог
// подключиться заново, но и не отключался никогда.
//
// Поэтому набор состоит в основном из равенств: `quota == usage`,
// `now == expiresAt`, `now == bannedUntil`, `quota == 0`.
const policyNow = int64(1_700_000_000_000)
// policyPeer собирает пира с полным набором решающих полей.
func policyPeer(disabled, quota, download, upload, expiresAt, bannedUntil int64) entity.Peer {
return entity.Peer{
Disabled: &disabled,
QuotaBytes: &quota,
DownloadBytes: &download,
UploadBytes: &upload,
ExpiresAt: &expiresAt,
BannedUntil: &bannedUntil,
}
}
func TestPeerAccessDeniedBoundaries(t *testing.T) {
cases := []struct {
name string
peer entity.Peer
denied bool
}{
{
name: "обычный пир без ограничений",
peer: policyPeer(0, -1, 1_000, 2_000, 0, 0),
denied: false,
},
{
name: "отключён оператором",
peer: policyPeer(1, -1, 0, 0, 0, 0),
denied: true,
},
// --- квота ---
{
// Единственный способ сказать «без ограничения».
name: "quota = -1 — безлимит при любом расходе",
peer: policyPeer(0, -1, 1<<40, 1<<40, 0, 0),
denied: false,
},
{
// Ноль байтов — это ноль байтов, а не отсутствие ограничения.
// Прежний cron на такой строке сессию не рвал вовсе.
name: "quota = 0 — доступа нет",
peer: policyPeer(0, 0, 0, 0, 0, 0),
denied: true,
},
{
// Главная граница QUOTA-01: счётчики растут порциями по ответу
// Traffic Stats API, поэтому точное равенство — обычный исход
// последнего сбора, а не экзотика.
name: "usage = quota — лимит исчерпан",
peer: policyPeer(0, 1_000, 600, 400, 0, 0),
denied: true,
},
{
name: "usage на байт меньше квоты",
peer: policyPeer(0, 1_000, 600, 399, 0, 0),
denied: false,
},
{
name: "usage больше квоты",
peer: policyPeer(0, 1_000, 600, 401, 0, 0),
denied: true,
},
{
// Квота считается по сумме обоих направлений.
name: "квота исчерпана одним download",
peer: policyPeer(0, 1_000, 1_000, 0, 0, 0),
denied: true,
},
// --- срок действия ---
{
name: "expiresAt = 0 — срок не задан",
peer: policyPeer(0, -1, 0, 0, 0, 0),
denied: false,
},
{
// Момент наступил — значит уже истёк.
name: "expiresAt = now — срок истёк",
peer: policyPeer(0, -1, 0, 0, policyNow, 0),
denied: true,
},
{
name: "expiresAt на миллисекунду впереди",
peer: policyPeer(0, -1, 0, 0, policyNow+1, 0),
denied: false,
},
{
name: "expiresAt в прошлом",
peer: policyPeer(0, -1, 0, 0, policyNow-1, 0),
denied: true,
},
// --- временная блокировка ---
{
name: "bannedUntil = 0 — блокировки нет",
peer: policyPeer(0, -1, 0, 0, 0, 0),
denied: false,
},
{
// Блокировка «до» этого момента уже закончилась.
name: "bannedUntil = now — блокировка истекла",
peer: policyPeer(0, -1, 0, 0, 0, policyNow),
denied: false,
},
{
name: "bannedUntil в будущем",
peer: policyPeer(0, -1, 0, 0, 0, policyNow+1),
denied: true,
},
{
name: "bannedUntil в прошлом",
peer: policyPeer(0, -1, 0, 0, 0, policyNow-1),
denied: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := peerAccessDenied(tc.peer, policyNow); got != tc.denied {
t.Fatalf("peerAccessDenied = %v, ожидалось %v", got, tc.denied)
}
})
}
}
// Строка без решающего поля — повреждённые данные, а не пир без ограничений.
// Все эти колонки объявлены NOT NULL с DEFAULT, поэтому nil здесь означать
// может только повреждение, и на пути принятия решения о доступе оно обязано
// вести к отказу.
func TestPeerAccessDeniedIsFailClosedOnCorruptedRow(t *testing.T) {
fields := map[string]func(*entity.Peer){
"disabled": func(p *entity.Peer) { p.Disabled = nil },
"quota_bytes": func(p *entity.Peer) { p.QuotaBytes = nil },
"download_bytes": func(p *entity.Peer) { p.DownloadBytes = nil },
"upload_bytes": func(p *entity.Peer) { p.UploadBytes = nil },
"expires_at": func(p *entity.Peer) { p.ExpiresAt = nil },
"banned_until": func(p *entity.Peer) { p.BannedUntil = nil },
}
for field, corrupt := range fields {
t.Run(field, func(t *testing.T) {
// Исходный пир заведомо имеет доступ: отказ обязан прийти именно
// от повреждения, а не от прочих ограничений.
peer := policyPeer(0, -1, 0, 0, 0, 0)
corrupt(&peer)
if !peerAccessDenied(peer, policyNow) {
t.Fatalf("строка без %s принята как пир без ограничений", field)
}
})
}
}
// Авторизация и принудительное отключение обязаны отвечать ОДИНАКОВО на любом
// состоянии: расхождение между ними — это и есть класс дефектов QUOTA-01.
//
// Проверяется свойство, а не реализация: обе стороны спрашивают одну функцию,
// поэтому тест ловит попытку завести второй предикат, а не текущий код.
func TestPeerAccessDecisionIsTotal(t *testing.T) {
values := []int64{-1, 0, 1_000}
times := []int64{policyNow - 1, policyNow, policyNow + 1}
for _, quota := range values {
for _, used := range []int64{0, 500, 1_000, 1_500} {
for _, expires := range append([]int64{0}, times...) {
for _, banned := range append([]int64{0}, times...) {
peer := policyPeer(0, quota, used, 0, expires, banned)
first := peerAccessDenied(peer, policyNow)
second := peerAccessDenied(peer, policyNow)
if first != second {
t.Fatalf(
"решение недетерминировано: quota=%d used=%d expires=%d banned=%d",
quota, used, expires, banned,
)
}
}
}
}
}
}
+727
View File
@@ -0,0 +1,727 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
)
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
// клиента.
//
// Контракт, который здесь доказывается, — официальный Traffic Stats API
// Hysteria: метод, путь, заголовок Authorization и JSON-массив идентификаторов
// в теле. Подменённый на уровне Go клиент доказал бы только то, что вызвана
// нужная функция, и молча пережил бы, например, потерю заголовка. Слой proxy
// ходит на 127.0.0.1:<порт из конфига>, а httptest слушает ровно там, поэтому
// настоящий запрос ставится без единой правки продуктового кода.
const testTrafficStatsSecret = "test-traffic-stats-secret"
// trafficStatsStub — Traffic Stats API Hysteria в объёме, который использует
// админка.
type trafficStatsStub struct {
mu sync.Mutex
// online отдаётся на GET /online.
online map[string]int64
// onlineStatus != 0 подменяет ответ /online кодом отказа.
onlineStatus int
// kickStatus != 0 подменяет ответ /kick кодом отказа.
kickStatus int
// Наблюдения.
onlineCalls int
kickCalls int
kickedKeys [][]string
kickAuth []string
// disabledAtKick — состояние колонки disabled на момент прихода /kick.
// Именно этим доказывается порядок «сначала запись, потом разрыв»:
// проверить его после операции нельзя, там оба шага уже завершены.
disabledAtKick []map[string]int64
bannedAtKick []map[string]int64
}
func (s *trafficStatsStub) snapshot(t *testing.T) (map[string]int64, map[string]int64) {
t.Helper()
disabled := map[string]int64{}
banned := map[string]int64{}
peers, err := dao.ListPeer("1=1")
if err != nil {
return disabled, banned
}
for _, peer := range peers {
if peer.Name == nil {
continue
}
if peer.Disabled != nil {
disabled[*peer.Name] = *peer.Disabled
}
if peer.BannedUntil != nil {
banned[*peer.Name] = *peer.BannedUntil
}
}
return disabled, banned
}
// startTrafficStats поднимает Traffic Stats API на 127.0.0.1 и указывает на
// него конфиг Hysteria, который читает админка.
func startTrafficStats(t *testing.T, stub *trafficStatsStub) *trafficStatsStub {
t.Helper()
if stub == nil {
stub = &trafficStatsStub{}
}
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 "/online":
stub.onlineCalls++
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++
stub.kickAuth = append(stub.kickAuth, r.Header.Get("Authorization"))
disabled, banned := stub.snapshot(t)
stub.disabledAtKick = append(stub.disabledAtKick, disabled)
stub.bannedAtKick = append(stub.bannedAtKick, banned)
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
}
// pointHysteriaConfigAt пишет конфиг Hysteria, в котором Traffic Stats API
// слушает порт переданного адреса.
func pointHysteriaConfigAt(t *testing.T, serverURL string) {
t.Helper()
host := serverURL
for _, prefix := range []string{"http://", "https://"} {
if len(host) > len(prefix) && host[:len(prefix)] == prefix {
host = host[len(prefix):]
}
}
_, port, err := net.SplitHostPort(host)
if err != nil {
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", serverURL, err)
}
path := filepath.Join(t.TempDir(), "config.yaml")
content := fmt.Sprintf("listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:%s\"\n secret: %q\n",
port, testTrafficStatsSecret)
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 })
}
// pointHysteriaConfigAtDeadPort указывает конфиг на порт, который никто не
// слушает: так воспроизводится недоступность Traffic Stats API.
func pointHysteriaConfigAtDeadPort(t *testing.T) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("не удалось занять порт: %v", err)
}
addr := listener.Addr().String()
// Порт освобождается сразу: адрес известен, слушателя на нём нет.
if err := listener.Close(); err != nil {
t.Fatalf("не удалось освободить порт: %v", err)
}
pointHysteriaConfigAt(t, "http://"+addr)
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
}
// withHysteriaServiceState подменяет ответ systemd о состоянии службы.
func withHysteriaServiceState(t *testing.T, state HysteriaServiceState) {
t.Helper()
previous := hysteriaServiceState
hysteriaServiceState = func() HysteriaServiceState { return state }
t.Cleanup(func() { hysteriaServiceState = previous })
}
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
t.Helper()
peer, ok := snapshotPeers(t)[name]
if !ok {
t.Fatalf("пир %q не найден", name)
}
if peer.Disabled != nil {
disabled = *peer.Disabled
}
if peer.BannedUntil != nil {
bannedUntil = *peer.BannedUntil
}
return disabled, bannedUntil
}
func requireDisconnectError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("частичный результат не сообщён: ошибки нет вовсе")
}
var peerErr *PeerError
if !errors.As(err, &peerErr) {
t.Fatalf("отказ не несёт машиночитаемого кода: %v", err)
}
if peerErr.Code != constant.ErrCodePeerDisconnectFailed {
t.Fatalf("ожидался код %q, получен %q", constant.ErrCodePeerDisconnectFailed, peerErr.Code)
}
if peerErr.Field != "" {
t.Errorf("частичный результат отнесён к полю формы %q, хотя относится к операции целиком", peerErr.Field)
}
}
// --- disconnectAuthIDs -------------------------------------------------------
func TestDisconnectAuthIDsSendsOfficialKickContract(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
t.Fatalf("разрыв сессии отказал: %v", err)
}
if stub.kickCalls != 1 {
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
}
if got := stub.kickedKeys[0]; len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("в /kick уехали не те идентификаторы: %v", got)
}
if stub.kickAuth[0] != testTrafficStatsSecret {
t.Fatalf("секрет Traffic Stats API не передан в Authorization: %q", stub.kickAuth[0])
}
}
// Разрыв сессии НИЧЕГО не пишет в базу. Прежний Hysteria2Kick заодно
// проставлял banned_until, из-за чего им нельзя было воспользоваться для
// операции «Отключить»: она записала бы временную блокировку — другой механизм
// с другим сроком жизни.
func TestDisconnectAuthIDsDoesNotTouchPeerState(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
seedPeer(t, "alpha1", "alpha-auth-id")
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
t.Fatalf("разрыв сессии отказал: %v", err)
}
disabled, bannedUntil := peerByName(t, "alpha1")
if disabled != 0 {
t.Errorf("разрыв сессии изменил disabled: %d", disabled)
}
if bannedUntil != 0 {
t.Errorf("разрыв сессии изменил banned_until: %d", bannedUntil)
}
}
// Запрос без единой цели не отправляется вовсе. Раньше по аналогичному пути в
// cron уезжал POST с пустым массивом в теле каждые 30 секунд.
func TestDisconnectAuthIDsIsNoopWithoutTargets(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
if err := disconnectAuthIDs(nil); err != nil {
t.Fatalf("пустой список признан отказом: %v", err)
}
// Пир без authId Hysteria не знает: рвать нечего.
if err := disconnectAuthIDs([]string{"", ""}); err != nil {
t.Fatalf("набор из пустых идентификаторов признан отказом: %v", err)
}
if stub.kickCalls != 0 {
t.Fatalf("сделан вызов /kick без единой цели: %d", stub.kickCalls)
}
}
// Один и тот же идентификатор — один разрыв. Импорт и правка пира легко дают
// повторы, и слать их в Hysteria по разу на каждое вхождение незачем.
func TestDisconnectAuthIDsDeduplicates(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
if err := disconnectAuthIDs([]string{"a", "b", "a", "", "b", "a"}); err != nil {
t.Fatalf("разрыв сессии отказал: %v", err)
}
if stub.kickCalls != 1 {
t.Fatalf("ожидался один вызов /kick, было %d", stub.kickCalls)
}
if got := stub.kickedKeys[0]; len(got) != 2 {
t.Fatalf("дубликаты уехали в Hysteria: %v", got)
}
}
// Размер запроса задаёт продукт, а не содержимое пользовательского файла:
// импорт применяет до MaxPeerImportItems записей за операцию.
func TestDisconnectAuthIDsSplitsLargeBatches(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
authIDs := make([]string, 0, kickChunkSize*2+5)
for i := 0; i < cap(authIDs); i++ {
authIDs = append(authIDs, fmt.Sprintf("auth-%d", i))
}
if err := disconnectAuthIDs(authIDs); err != nil {
t.Fatalf("разрыв сессии отказал: %v", err)
}
if stub.kickCalls != 3 {
t.Fatalf("ожидалось 3 обращения к /kick, было %d", stub.kickCalls)
}
total := 0
for _, keys := range stub.kickedKeys {
if len(keys) > kickChunkSize {
t.Fatalf("чанк больше предела: %d", len(keys))
}
total += len(keys)
}
if total != len(authIDs) {
t.Fatalf("потеряны идентификаторы: отправлено %d из %d", total, len(authIDs))
}
}
func TestDisconnectAuthIDsReportsApiFailure(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err == nil {
t.Fatal("отказ Traffic Stats API не сообщён")
}
}
// Состояние службы по systemd на этом пути не спрашивается вовсе: ответ
// systemctl не отличает «служба неактивна» от «спросить не удалось», и на
// прежнем пути его сбой отказывал операции при живой Hysteria.
func TestDisconnectAuthIDsIgnoresSystemdOpinion(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
withHysteriaServiceState(t, HysteriaServiceInactive)
if err := disconnectAuthIDs([]string{"alpha-auth-id"}); err != nil {
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
}
if stub.kickCalls != 1 {
t.Fatalf("разрыв сессии пропущен из-за мнения systemd: вызовов /kick %d", stub.kickCalls)
}
}
// --- Отключение пира ---------------------------------------------------------
func TestUpdatePeerDisableWritesThenDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
t.Fatalf("отключение пира отказало: %v", err)
}
disabled, bannedUntil := peerByName(t, "alpha1")
if disabled != 1 {
t.Fatalf("пир не отключён: disabled=%d", disabled)
}
// Временная блокировка — независимый механизм, отключение её не ставит.
if bannedUntil != 0 {
t.Errorf("отключение проставило banned_until=%d", bannedUntil)
}
if stub.kickCalls != 1 {
t.Fatalf("активная сессия не разорвана: вызовов /kick %d", stub.kickCalls)
}
// Порядок: к моменту прихода /kick запрет новых подключений уже в базе.
// При обратном порядке клиент успевает переподключиться в окне между
// разрывом и записью и остаётся на связи с формально отключённым пиром.
if got := stub.disabledAtKick[0]["alpha1"]; got != 1 {
t.Fatalf("/kick пришёл раньше записи disabled: на момент разрыва disabled=%d", got)
}
}
// Неудача разрыва НЕ откатывает запрет новых подключений: безопасная половина
// операции достигнута, и возвращать пиру полный доступ из-за второго шага
// нельзя.
func TestUpdatePeerDisableKeepsStateWhenDisconnectFails(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one})
requireDisconnectError(t, err)
if disabled, _ := peerByName(t, "alpha1"); disabled != 1 {
t.Fatalf("запрет новых подключений откачен после неудачного разрыва: disabled=%d", disabled)
}
}
// Включение пира не рвёт сессий и не трогает временную блокировку: это
// независимые механизмы.
func TestUpdatePeerEnableDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := KickPeer(id, 1_700_000_000_000); err != nil {
t.Fatalf("временная блокировка отказала: %v", err)
}
kicksAfterBan := stub.kickCalls
zero := int64(0)
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &zero}); err != nil {
t.Fatalf("включение пира отказало: %v", err)
}
if stub.kickCalls != kicksAfterBan {
t.Fatalf("включение пира вызвало /kick: %d -> %d", kicksAfterBan, stub.kickCalls)
}
disabled, bannedUntil := peerByName(t, "alpha1")
if disabled != 0 {
t.Fatalf("пир не включён: disabled=%d", disabled)
}
if bannedUntil != 1_700_000_000_000 {
t.Errorf("включение сбросило независимую временную блокировку: banned_until=%d", bannedUntil)
}
}
// Правка постороннего поля не является отзывом доступа и сессий не рвёт.
func TestUpdatePeerUnrelatedFieldDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{Remark: strPtr("ноутбук")}); err != nil {
t.Fatalf("правка комментария отказала: %v", err)
}
if stub.kickCalls != 0 {
t.Fatalf("правка комментария разорвала сессии: вызовов /kick %d", stub.kickCalls)
}
}
// Операция повторяема: условие смотрит на ЗАПРОШЕННОЕ состояние, а не на
// переход из включённого. Иначе после неудачного разрыва оператору пришлось бы
// включить пира, чтобы получить право отключить его снова.
func TestUpdatePeerDisableIsRetryable(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}))
stub.mu.Lock()
stub.kickStatus = 0
stub.mu.Unlock()
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
t.Fatalf("повторное отключение отказало: %v", err)
}
if stub.kickCalls != 2 {
t.Fatalf("повторная попытка не дошла до /kick: вызовов %d", stub.kickCalls)
}
}
// --- Временная блокировка ----------------------------------------------------
func TestKickPeerWritesBanOnceAndDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
const bannedUntil = int64(1_700_000_000_000)
if err := KickPeer(id, bannedUntil); err != nil {
t.Fatalf("временная блокировка отказала: %v", err)
}
disabled, banned := peerByName(t, "alpha1")
if banned != bannedUntil {
t.Fatalf("banned_until не записан: %d", banned)
}
// Временная блокировка и отключение независимы.
if disabled != 0 {
t.Errorf("временная блокировка изменила disabled: %d", disabled)
}
if stub.kickCalls != 1 {
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
}
if got := stub.bannedAtKick[0]["alpha1"]; got != bannedUntil {
t.Fatalf("/kick пришёл раньше записи banned_until: на момент разрыва %d", got)
}
}
// Регрессия: прежний путь начинался с проверки состояния службы и возвращал
// чистый отказ, НЕ СДЕЛАВ ничего, — при том что запись banned_until к этому
// моменту уже применилась. Оператор видел «не сработало» у сработавшей
// блокировки.
func TestKickPeerReportsPartialResultWhenDisconnectFails(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
const bannedUntil = int64(1_700_000_000_000)
requireDisconnectError(t, KickPeer(id, bannedUntil))
if _, banned := peerByName(t, "alpha1"); banned != bannedUntil {
t.Fatalf("блокировка не применена, хотя сообщён частичный результат: banned_until=%d", banned)
}
}
func TestReleaseKickPeerLeavesDisabledAlone(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
t.Fatalf("отключение пира отказало: %v", err)
}
if err := KickPeer(id, 1_700_000_000_000); err != nil {
t.Fatalf("временная блокировка отказала: %v", err)
}
if err := ReleaseKickPeer(id); err != nil {
t.Fatalf("снятие блокировки отказало: %v", err)
}
disabled, banned := peerByName(t, "alpha1")
if banned != 0 {
t.Fatalf("banned_until не сброшен: %d", banned)
}
if disabled != 1 {
t.Fatalf("снятие временной блокировки включило отключённого пира: disabled=%d", disabled)
}
}
// --- Ограничение устройств (fail-closed) -------------------------------------
func TestHysteria2AuthRejectsWhenTrafficStatsRefuses(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("отказ Traffic Stats API впустил подключение без проверки лимита устройств")
}
}
func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("недоступность Traffic Stats API впустила подключение без проверки лимита устройств")
}
}
// Регрессия AUTH-02, вторая половина дыры и самая тихая: общий Hysteria2Online
// отдавал ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», —
// а этот ответ не отличается от «спросить systemctl не удалось». Лимит
// устройств переставал проверяться, и в журнале не появлялось ни строки.
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
withHysteriaServiceState(t, HysteriaServiceInactive)
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("мнение systemd отключило проверку лимита устройств")
}
}
func TestHysteria2AuthAcceptsBelowDeviceLimit(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
gotID, gotAuth, err := Hysteria2Auth("alpha1-secret")
if err != nil {
t.Fatalf("подключение в пределах лимита отклонено: %v", err)
}
if gotID != id || gotAuth != "alpha-auth-id" {
t.Fatalf("авторизация вернула не того пира: id=%d authId=%q", gotID, gotAuth)
}
}
func TestHysteria2AuthRejectsAtDeviceLimit(t *testing.T) {
newTestDB(t)
// seedPeer создаёт пира с maxDevices = 3.
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("подключение сверх лимита устройств принято")
}
}
// Повреждённая граница — это не «безлимит». Строка без пригодного maxDevices
// на пути принятия решения о доступе обязана вести к отказу.
func TestHysteria2AuthRejectsUnusableDeviceLimit(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"max_devices": 0}); err != nil {
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
}
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("пир без пригодного лимита устройств принят")
}
}
// Путь машинной авторизации проходят ВСЕ подключения пиров, поэтому
// разыменование nil на нём означает панику в обработчике, а не отказ одному
// клиенту.
func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"auth_id": ""}); err != nil {
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
}
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("повреждённая строка уронила обработчик авторизации: %v", recovered)
}
}()
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("пир без идентичности принят")
}
}
// Путь отображения БОЛЬШЕ НЕ выдаёт «никто не подключён» за ответ.
//
// Прежний контракт был терпимым: при неактивной по мнению systemd службе
// возвращалась пустая карта без ошибки. Выглядело это как честный ответ «сейчас
// никого нет», а означало «спросить не удалось либо не спрашивали вовсе» —
// ответ systemctl не отличает «служба неактивна» от «systemctl не запустился».
//
// Цена была не в формулировке. Сборщик метрик получал `err == nil` и выставлял
// `apiReachable = true`, ни разу не обратившись к Traffic Stats API, поэтому
// дашборд утверждал одновременно «Hysteria остановлена» и «API доступен, онлайн
// 0». Список пиров по тому же ответу показывал всех офлайн.
//
// Теперь недоступность остаётся ошибкой, а как показать её оператору, решает
// вызывающий — см. TestPagePeerReportsUnknownOnlineState.
func TestHysteria2OnlineReportsFailureInsteadOfEmptyPicture(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
withHysteriaServiceState(t, HysteriaServiceInactive)
if _, err := Hysteria2Online(); err == nil {
t.Fatal("недоступность Traffic Stats API выдана за пустую картину подключений")
}
}
// Мнение systemd на картину подключений не влияет вовсе: спрашивают того, кто
// знает ответ.
func TestHysteria2OnlineIgnoresSystemdOpinion(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
withHysteriaServiceState(t, HysteriaServiceInactive)
online, err := Hysteria2Online()
if err != nil {
t.Fatalf("картина подключений отказала из-за мнения systemd: %v", err)
}
if online["alpha-auth-id"] != 2 {
t.Fatalf("мнение systemd подменило фактический ответ API: %v", online)
}
}
// Список пиров при недоступном API сообщает «состояние неизвестно», а не «все
// офлайн»: это разные ответы, и первый в аварии ведёт оператора искать
// проблему у пользователей.
func TestPagePeerReportsUnknownOnlineState(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
seedPeer(t, "alpha1", "alpha-auth-id")
records, total, onlineState, err := PagePeer(dto.PeerPageDto{})
if err != nil {
t.Fatalf("список пиров отказал из-за недоступного Traffic Stats API: %v", err)
}
if total != 1 || len(records) != 1 {
t.Fatalf("список пиров потерян: записей %d, всего %d", len(records), total)
}
if onlineState != vo.PeerOnlineStateUnavailable {
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateUnavailable)
}
}
func TestPagePeerReportsKnownOnlineState(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
seedPeer(t, "alpha1", "alpha-auth-id")
records, _, onlineState, err := PagePeer(dto.PeerPageDto{})
if err != nil {
t.Fatalf("список пиров отказал: %v", err)
}
if onlineState != vo.PeerOnlineStateOk {
t.Fatalf("состояние подключений объявлено как %q, ожидалось %q", onlineState, vo.PeerOnlineStateOk)
}
if len(records) != 1 || !records[0].Online || records[0].OnlineDevices != 2 {
t.Fatalf("картина подключений не доехала до строки: %+v", records)
}
}
+318
View File
@@ -0,0 +1,318 @@
package service
import (
"sync"
"time"
)
// Лимит устройств выдерживает ПАРАЛЛЕЛЬНЫЕ запросы авторизации.
//
// Что было. Проверка выглядела так:
//
// onlineUsers, err := hysteria2Online()
// if device, exist := onlineUsers[authID]; exist && maxDevices <= device {
// return deny
// }
// return allow
//
// Между чтением `/online` и ответом «allow» нет ничего, что удержало бы место,
// поэтому при одновременных подключениях выходило:
//
// A: GET /online -> 2 B: GET /online -> 2
// max = 3
// A: 2 < 3 -> allow B: 2 < 3 -> allow
// стало 4
//
// Объявленный в панели «Лимит устройств: 3» превышался ровно тем способом,
// от которого лимит и должен защищать.
//
// Обычный мьютекс вокруг `/online` проблему не решает, и это главное, что
// нужно понимать про этот файл. Ответив «allow», админка не создаёт
// подключение — его только начинает устанавливать Hysteria, и в статистику
// клиент попадает позже. Следующий `/online`, даже строго после первого,
// продолжает показывать прежнее число. Сериализация запросов лишь сузила бы
// окно, оставив дефект на месте.
//
// Поэтому админка ведёт собственный учёт уже выданных, но ещё не проявившихся
// разрешений. HY2XS — один процесс на одном сервере с Hysteria, поэтому учёт
// process-local: ни Redis, ни таблицы в базе, ни распределённых блокировок для
// этого не нужно.
//
// Чего этот механизм НЕ обещает. Без обратного вызова от Hysteria
// «соединение установлено / не установлено» математически точной системы
// резервирования не построить. Он закрывает конкретный и реальный TOCTOU —
// параллельные HTTP-auth одного процесса, — и делает это fail-closed.
//
// Второй TOCTOU: ПЕРЕУПОРЯДОЧИВАНИЕ снимков `/online`.
//
// Учёта выданных разрешений самого по себе оказалось недостаточно, и это
// отдельный дефект, а не оттенок первого. Сетевой запрос выполнялся вне
// мьютекса, поэтому снимки приходили в критическую секцию в произвольном
// порядке — более старый мог обогнать более новый:
//
// 1. A получает разрешение при `/online = 0`; pending = [A], lastOnline = 0
// 2. B читает `/online = 0` и задерживается на обратном пути
// 3. A действительно подключается, Hysteria показывает `/online = 1`
// 4. C читает `/online = 1` и входит в резервацию ПЕРВЫМ:
// разрешение A признано проявившимся, lastOnline = 1, C получает отказ
// 5. B входит со своим устаревшим `online = 0`
// 6. `online > lastOnline` ложно, после чего lastOnline откатывается в 0
// 7. `0 + pending(0) < 1` -> B получает ALLOW
//
// При `maxDevices = 1` подключений становится два. Это НЕ data race: вся
// работа с памятью защищена мьютексом, поэтому детектор гонок здесь молчит
// принципиально, и поймать дефект может только семантическая проверка.
//
// Лечится это не глобальным мьютексом вокруг сети — он сериализовал бы
// подключения всех пиров через один HTTP-обмен, — а замком на ОДИН authID:
// см. lockPeerAdmission. Конкурируют между собой только авторизации одного и
// того же пира, а их упорядоченность и есть требуемое свойство: снимок,
// прочитанный под замком, не может оказаться старше уже обработанного.
//
// Оба механизма нужны одновременно и закрывают разные половины:
//
// замок по authID — снимки не переупорядочиваются;
// учёт разрешений — снимок не успевает измениться к следующему запросу.
// pendingAdmissionTTL — срок жизни выданного разрешения, которое ещё не
// проявилось в `/online`.
//
// Величина внутренняя и пользовательской настройкой не является намеренно: это
// не политика доступа, а компенсация задержки между ответом авторизации и
// появлением клиента в статистике Hysteria. Настройка, смысл которой оператор
// не может оценить, порождает только неверные значения.
//
// Тридцать секунд — с большим запасом относительно установления QUIC-сессии и
// при этом заметно меньше, чем интервал, на котором оператор вообще заметил бы
// занятый слот. Если клиент авторизовался, но так и не подключился, резервация
// исчезает сама.
const pendingAdmissionTTL = 30 * time.Second
// admissionState — учёт по одному пиру.
type admissionState struct {
// lastOnline — число устройств, показанное Traffic Stats API в прошлый
// раз. Нужно, чтобы отличить рост (клиент подключился, резервация
// проявилась) от неизменного значения.
lastOnline int64
// pending — сроки годности выданных, но ещё не проявившихся разрешений.
// Хранится по одному значению на разрешение, а не счётчиком: иначе
// протухать они могли бы только все разом.
pending []time.Time
}
var deviceAdmissions = struct {
sync.Mutex
byAuthID map[string]*admissionState
}{byAuthID: map[string]*admissionState{}}
// admissionGate — замок одного authID вместе со счётчиком тех, кому он сейчас
// нужен.
//
// Счётчик существует ради удаления записи. Без него карта замков росла бы по
// одной записи на каждый когда-либо авторизовавшийся authId и не уменьшалась
// бы никогда — то есть та же утечка, от которой в учёте разрешений защищает
// forgetIfIdle, только этажом выше.
type admissionGate struct {
mu sync.Mutex
// waiting — сколько вызывающих держат замок или ждут его. Пока значение
// больше нуля, запись обязана оставаться в карте: удалив её, второй
// вызывающий создал бы НОВЫЙ замок и разошёлся бы с первым.
waiting int
}
var admissionGates = struct {
sync.Mutex
byAuthID map[string]*admissionGate
}{byAuthID: map[string]*admissionGate{}}
// lockPeerAdmission сериализует последовательность «прочитать `/online` ->
// занять место» для ОДНОГО пира и возвращает функцию освобождения.
//
// Замок именно по authID, а не один на процесс, и это существенно. Внутри него
// выполняется сетевой запрос к Traffic Stats API, поэтому общий замок означал
// бы, что все подключения всех пиров выстраиваются в очередь за одним
// HTTP-обменом. Здесь же конкурируют только авторизации одного пира — то есть
// ровно те, для которых порядок и решается.
//
// Время удержания ограничено сверху таймаутом самого обращения к Traffic Stats
// API (proxy.Hysteria2Api ставит контекст на 3 секунды), поэтому «застрявшая»
// Hysteria не превращает замок в бессрочный.
//
// Карта замков и карта учёта разрешений намеренно раздельны: первая описывает,
// кто сейчас проходит авторизацию, вторая — что уже выдано. Совмещение их в
// одной структуре означало бы удерживать мьютекс учёта на время сетевого
// запроса.
func lockPeerAdmission(authID string) func() {
admissionGates.Lock()
gate := admissionGates.byAuthID[authID]
if gate == nil {
gate = &admissionGate{}
admissionGates.byAuthID[authID] = gate
}
gate.waiting++
admissionGates.Unlock()
gate.mu.Lock()
return func() {
gate.mu.Unlock()
admissionGates.Lock()
defer admissionGates.Unlock()
gate.waiting--
if gate.waiting == 0 {
delete(admissionGates.byAuthID, authID)
}
}
}
// reserveDeviceSlot решает, есть ли для нового подключения свободное место, и
// занимает его.
//
// Возвращает true, если подключение можно разрешить.
//
// Сетевой запрос к `/online` выполняется ВНЕ этого мьютекса — вызывающий
// передаёт сюда уже полученное число. Под блокировкой остаются только
// несколько операций с map: держать её на время HTTP-обмена значило бы
// сериализовать все подключения всех пиров через один сетевой запрос.
//
// Упорядоченность снимков обеспечивает не этот мьютекс, а замок по authID:
// вызывающий обязан удерживать lockPeerAdmission от чтения `/online` и до
// возврата отсюда. Без него сюда попадал бы снимок старше уже обработанного, и
// строка `state.lastOnline = online` откатывала бы учёт назад — см. описание
// второго TOCTOU в начале файла.
func reserveDeviceSlot(authID string, online int64, maxDevices int64, now time.Time) bool {
deviceAdmissions.Lock()
defer deviceAdmissions.Unlock()
state := deviceAdmissions.byAuthID[authID]
if state == nil {
state = &admissionState{}
deviceAdmissions.byAuthID[authID] = state
}
// 1. Протухшие разрешения освобождают место: клиент, который авторизовался
// и не подключился, не должен занимать слот вечно.
state.dropExpired(now)
// 2. Рост числа онлайн-устройств означает, что ровно столько выданных
// разрешений уже превратились в подключения. Не сняв их, админка
// посчитала бы одно и то же устройство дважды — сначала как резервацию,
// потом как реальное подключение, — и лимит стал бы вдвое строже
// объявленного.
if online > state.lastOnline {
state.dropMaterialized(online - state.lastOnline)
}
state.lastOnline = online
// 3. Решение принимается по сумме: подтверждённые подключения плюс ещё не
// проявившиеся разрешения.
if online+int64(len(state.pending)) >= maxDevices {
// Место не занято и запись может оказаться ненужной: убираем её, чтобы
// карта не росла от одних отказов.
state.forgetIfIdle(authID)
return false
}
state.pending = append(state.pending, now.Add(pendingAdmissionTTL))
return true
}
// Функции «вернуть занятое место» здесь нет намеренно. После выдачи разрешения
// в Hysteria2Auth не остаётся ни одного шага, способного отказать, а
// controller.Hysteria2Auth на успешном ответе только обновляет
// last_connection_at и неудачу этого обновления считает несущественной.
// Освобождение, у которого нет вызывающего, было бы вторым способом менять
// состояние трекера — и первым кандидатом разойтись с reserveDeviceSlot.
// Разрешение, за которым не последовало подключения, снимает TTL.
func (s *admissionState) dropExpired(now time.Time) {
kept := s.pending[:0]
for _, deadline := range s.pending {
if deadline.After(now) {
kept = append(kept, deadline)
}
}
s.pending = kept
}
// dropMaterialized снимает count самых старых разрешений: раньше выдано —
// раньше подключилось.
func (s *admissionState) dropMaterialized(count int64) {
if count >= int64(len(s.pending)) {
s.pending = s.pending[:0]
return
}
s.pending = s.pending[count:]
}
// forgetIfIdle убирает запись, о которой больше нечего помнить.
//
// Без этого карта росла бы по одной записи на каждый когда-либо
// авторизовавшийся authId и не уменьшалась бы никогда — включая записи
// давно удалённых пиров.
func (s *admissionState) forgetIfIdle(authID string) {
if len(s.pending) == 0 && s.lastOnline == 0 {
delete(deviceAdmissions.byAuthID, authID)
}
}
// sweepDeviceAdmissions убирает записи о пирах, за которыми ничего не числится.
//
// Зачем это нужно отдельно от forgetIfIdle. Тот срабатывает ТОЛЬКО на ветке
// отказа: после успешной выдачи разрешения запись остаётся с непустым pending,
// а когда разрешение протухает, снять запись уже некому — следующего обращения
// к этому authId может не быть никогда. Так в карте оставались пиры, удалённые
// из панели, и старые authId, переписанные импортом: за время жизни процесса
// она только росла.
//
// Решение принимается по ФАКТИЧЕСКОЙ картине подключений, а не по хранимому
// lastOnline: последний обновляется только на пути авторизации, поэтому у
// отключившегося пира он остаётся прежним сколь угодно долго.
//
// Удаление записи, у которой нет ни одного действующего разрешения и нет
// подключений, не меняет ни одного будущего решения. Следующая резервация
// начнёт с чистой записи и придёт к тому же ответу: dropMaterialized на пустом
// списке — no-op, а `state.lastOnline` в любом случае перезаписывается
// пришедшим значением до сравнения с лимитом.
//
// Замок authID здесь не берётся намеренно: удерживать его на всём обходе карты
// значило бы останавливать авторизацию каждые 30 секунд. Гонка с параллельной
// авторизацией безопасна — она либо уже добавила разрешение (тогда pending не
// пуст и запись остаётся), либо ещё не дошла до учёта (тогда она создаст
// запись заново, и это ровно та же чистая запись).
func sweepDeviceAdmissions(online map[string]int64, now time.Time) {
deviceAdmissions.Lock()
defer deviceAdmissions.Unlock()
for authID, state := range deviceAdmissions.byAuthID {
state.dropExpired(now)
if len(state.pending) > 0 {
continue
}
if online[authID] > 0 {
continue
}
delete(deviceAdmissions.byAuthID, authID)
}
}
// resetDeviceAdmissions очищает учёт. Существует ради тестов: состояние здесь
// принадлежит процессу, и без сброса тесты видели бы резервации друг друга.
func resetDeviceAdmissions() {
deviceAdmissions.Lock()
deviceAdmissions.byAuthID = map[string]*admissionState{}
deviceAdmissions.Unlock()
// Замки сбрасывать НЕЛЬЗЯ: удерживаемый кем-то замок, потерянный из карты,
// перестал бы исключать второго вызывающего. Вместо сброса тесты проверяют,
// что после завершения работы карта пуста сама.
}
// heldPeerAdmissionGates — число живых замков. Существует ради тестов: утечка
// здесь выглядит именно как незакрытая запись в карте.
func heldPeerAdmissionGates() int {
admissionGates.Lock()
defer admissionGates.Unlock()
return len(admissionGates.byAuthID)
}
+673
View File
@@ -0,0 +1,673 @@
package service
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Лимит устройств проверяется на ПАРАЛЛЕЛЬНЫХ запросах авторизации.
//
// Прежняя проверка сравнивала ответ `/online` с maxDevices и сразу отвечала
// «allow»: между чтением и ответом место ничем не удерживалось, поэтому два
// одновременных подключения при `online = max-1` получали разрешение оба и
// объявленный лимит превышался.
//
// Последовательный тест этого не поймает никогда — нужен барьер, на котором
// оба запроса гарантированно видят ОДНО И ТО ЖЕ состояние Hysteria.
// --- Параллельные подключения одного пира ------------------------------------
// authResults прогоняет count одновременных авторизаций и возвращает их исходы.
func authResults(t *testing.T, secret string, count int) []error {
t.Helper()
var wg sync.WaitGroup
results := make([]error, count)
for i := range results {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, _, err := Hysteria2Auth(secret)
results[idx] = err
}(i)
}
wg.Wait()
return results
}
func allowedCount(results []error) int {
allowed := 0
for _, err := range results {
if err == nil {
allowed++
}
}
return allowed
}
// Главная регрессия AUTH-03: при `online = max-1` разрешение обязан получить
// ровно ОДИН из двух одновременных запросов.
//
// Барьера, заставлявшего оба запроса увидеть один снимок, здесь больше нет — и
// это следствие исправления, а не упрощение теста. Авторизации одного authId
// сериализованы замком (см. lockPeerAdmission), поэтому одновременно внутри
// `/online` они оказаться не могут, и барьер на двоих просто не собрался бы.
// Доказываемое свойство от этого не изменилось: `/online` отвечает обоим
// одинаково — именно так и ведёт себя Hysteria, пока клиент ещё устанавливает
// соединение, — и без учёта выданных разрешений оба сравнивали бы `2 < 3`.
func TestHysteria2AuthHoldsDeviceLimitUnderConcurrency(t *testing.T) {
newTestDB(t)
// seedPeer создаёт пира с maxDevices = 3, поэтому online = 2 — это
// последнее свободное место.
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
seedPeer(t, "alpha1", "alpha-auth-id")
if allowed := allowedCount(authResults(t, "alpha1-secret", 2)); allowed != 1 {
t.Fatalf("на последнее свободное место допущено %d подключений из 2", allowed)
}
}
// Свободных мест два — проходят оба: механизм не должен превращаться в отказ
// всем, кроме первого.
func TestHysteria2AuthAdmitsBothWhenTwoSlotsFree(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
seedPeer(t, "alpha1", "alpha-auth-id")
for idx, err := range authResults(t, "alpha1-secret", 2) {
if err != nil {
t.Fatalf("подключение %d отклонено при двух свободных местах: %v", idx, err)
}
}
}
// sequencedTrafficStats — Traffic Stats API, у которого ОДНО заранее названное
// обращение к `/online` удерживается до команды теста.
//
// Именно так воспроизводится переупорядочивание снимков: удерживаемый ответ
// содержит картину на момент ПРИХОДА запроса, а к моменту его доставки картина
// уже другая. Ответ формируется до блокировки намеренно — иначе тест проверял
// бы не устаревший снимок, а свежий.
type sequencedTrafficStats struct {
mu sync.Mutex
online map[string]int64
calls int
holdCall int
held chan struct{}
released chan struct{}
}
func startSequencedTrafficStats(t *testing.T, online map[string]int64, holdCall int) *sequencedTrafficStats {
t.Helper()
stats := &sequencedTrafficStats{
online: online,
holdCall: holdCall,
held: make(chan struct{}),
released: make(chan struct{}),
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/online":
stats.mu.Lock()
stats.calls++
call := stats.calls
snapshot := make(map[string]int64, len(stats.online))
for key, value := range stats.online {
snapshot[key] = value
}
stats.mu.Unlock()
if call == stats.holdCall {
close(stats.held)
select {
case <-stats.released:
case <-time.After(5 * time.Second):
// Тест не дал команды — отпускаем, чтобы падение было по
// существу, а не по таймауту всего прогона.
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(snapshot)
case "/kick":
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 stats
}
func (s *sequencedTrafficStats) setOnline(online map[string]int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.online = online
}
func (s *sequencedTrafficStats) awaitHeld(t *testing.T) {
t.Helper()
select {
case <-s.held:
case <-time.After(5 * time.Second):
t.Fatal("удерживаемое обращение к /online так и не пришло")
}
}
func (s *sequencedTrafficStats) release() {
close(s.released)
}
// Устаревший снимок `/online` не возвращает уже занятое место.
//
// Регрессия второго TOCTOU. Учёт выданных разрешений сам по себе его не
// закрывал: сетевой запрос выполнялся вне мьютекса, поэтому снимки приходили в
// резервацию в произвольном порядке, и более старый откатывал `lastOnline`
// назад:
//
// A получил разрешение при online = 0
// B прочитал online = 0 и задержался
// A подключился, Hysteria показывает online = 1
// C прочитал online = 1 и первым вошёл в резервацию:
// разрешение A признано проявившимся, lastOnline = 1, C отклонён
// B входит со своим устаревшим 0 -> lastOnline снова 0 -> B ДОПУЩЕН
//
// При maxDevices = 1 подключений становилось два. Детектор гонок здесь
// бесполезен принципиально: вся работа с памятью защищена мьютексом, и гонка
// тут логическая, а не по памяти.
func TestHysteria2AuthRejectsStaleOnlineSnapshot(t *testing.T) {
newTestDB(t)
// Удерживается ВТОРОЕ обращение к `/online` — то самое, которое в разборе
// принадлежит запросу B.
stats := startSequencedTrafficStats(t, map[string]int64{"alpha-auth-id": 0}, 2)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"max_devices": 1}); err != nil {
t.Fatalf("не удалось выставить лимит устройств: %v", err)
}
// A занимает единственное место.
if _, _, err := Hysteria2Auth("alpha1-secret"); err != nil {
t.Fatalf("первое подключение отклонено: %v", err)
}
// B читает `/online = 0` и застревает на обратном пути.
var bErr error
bDone := make(chan struct{})
go func() {
defer close(bDone)
_, _, bErr = Hysteria2Auth("alpha1-secret")
}()
stats.awaitHeld(t)
// A действительно подключился: Hysteria показывает одно устройство.
stats.setOnline(map[string]int64{"alpha-auth-id": 1})
// C авторизуется уже по НОВОМУ снимку. На исправленном коде он ждёт замок и
// до `/online` не доходит, поэтому ожидание ограничено: тест не должен
// зависеть от того, успел C или нет.
var cErr error
cDone := make(chan struct{})
go func() {
defer close(cDone)
_, _, cErr = Hysteria2Auth("alpha1-secret")
}()
select {
case <-cDone:
case <-time.After(300 * time.Millisecond):
}
stats.release()
<-bDone
<-cDone
if bErr == nil {
t.Fatal("устаревший снимок /online вернул уже занятое место: допущено второе устройство при лимите 1")
}
if cErr == nil {
t.Fatal("допущено второе устройство при лимите 1")
}
}
// --- Параллельные подключения разных пиров -----------------------------------
// barrierTrafficStats — Traffic Stats API, который задерживает первые `hold`
// обращений к `/online`, пока не придут все.
//
// Так проверяется, что замок авторизации НЕ является общим на процесс: два
// запроса разных пиров обязаны оказаться внутри `/online` одновременно. С
// глобальным замком барьер не собрался бы никогда.
type barrierTrafficStats struct {
mu sync.Mutex
arrived int
collected bool
release chan struct{}
}
func startBarrierTrafficStats(t *testing.T, online map[string]int64, hold int) *barrierTrafficStats {
t.Helper()
barrier := &barrierTrafficStats{release: make(chan struct{})}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/online":
barrier.mu.Lock()
barrier.arrived++
last := barrier.arrived == hold
if last {
barrier.collected = true
}
barrier.mu.Unlock()
if last {
close(barrier.release)
} else {
select {
case <-barrier.release:
case <-time.After(5 * time.Second):
// Барьер не собрался — отпускаем, чтобы тест упал по
// существу (см. requireCollected), а не по таймауту всего
// прогона.
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(online)
case "/kick":
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 barrier
}
// requireCollected требует, чтобы барьер действительно собрался.
//
// Без этой проверки тест проходил бы и при глобальном замке: барьер молча
// разошёлся бы по таймауту, а исходы авторизации остались бы прежними.
func (b *barrierTrafficStats) requireCollected(t *testing.T) {
t.Helper()
b.mu.Lock()
defer b.mu.Unlock()
if !b.collected {
t.Fatalf("запросы разных пиров не оказались в /online одновременно: пришло %d — авторизация сериализована глобально", b.arrived)
}
}
// Резервации принадлежат КОНКРЕТНОМУ пиру: занятое место одного не должно
// закрывать доступ другому, а замок одного не должен задерживать другого.
func TestDeviceAdmissionsAreIsolatedPerPeer(t *testing.T) {
newTestDB(t)
barrier := startBarrierTrafficStats(t, map[string]int64{"alpha-auth-id": 2, "bravo-auth-id": 0}, 2)
seedPeer(t, "alpha1", "alpha-auth-id")
seedPeer(t, "bravo2", "bravo-auth-id")
var wg sync.WaitGroup
var alphaErr, bravoErr error
wg.Add(2)
go func() {
defer wg.Done()
_, _, alphaErr = Hysteria2Auth("alpha1-secret")
}()
go func() {
defer wg.Done()
_, _, bravoErr = Hysteria2Auth("bravo2-secret")
}()
wg.Wait()
barrier.requireCollected(t)
if alphaErr != nil {
t.Fatalf("первое подключение пира на последнее место отклонено: %v", alphaErr)
}
if bravoErr != nil {
t.Fatalf("резервация чужого пира закрыла доступ: %v", bravoErr)
}
}
// Последовательно тот же лимит тоже держится: второй запрос видит место,
// занятое первым, хотя `/online` ещё показывает прежнее число.
func TestHysteria2AuthCountsPendingAdmissionSequentially(t *testing.T) {
newTestDB(t)
// Число НЕ меняется между запросами — именно так и ведёт себя Hysteria,
// пока клиент ещё устанавливает соединение.
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
seedPeer(t, "alpha1", "alpha-auth-id")
// max = 3, online = 1 -> свободно два места.
if _, _, err := Hysteria2Auth("alpha1-secret"); err != nil {
t.Fatalf("первое подключение отклонено: %v", err)
}
if _, _, err := Hysteria2Auth("alpha1-secret"); err != nil {
t.Fatalf("второе подключение отклонено: %v", err)
}
// Третье превысило бы лимит: 1 онлайн + 2 выданных разрешения.
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("подключение сверх лимита принято: выданные разрешения не учтены")
}
}
// --- Единица учёта ------------------------------------------------------------
const admissionAuthID = "auth-under-test"
func TestReserveDeviceSlotAllowsUpToLimit(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
for i := 1; i <= 3; i++ {
if !reserveDeviceSlot(admissionAuthID, 0, 3, now) {
t.Fatalf("разрешение %d из 3 отклонено", i)
}
}
if reserveDeviceSlot(admissionAuthID, 0, 3, now) {
t.Fatal("выдано четвёртое разрешение при лимите 3")
}
}
// Рост числа онлайн-устройств означает, что выданные разрешения превратились в
// подключения. Не сняв их, админка посчитала бы одно устройство дважды, и
// лимит стал бы вдвое строже объявленного.
func TestReserveDeviceSlotAbsorbsMaterializedAdmissions(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 0, 2, now) {
t.Fatal("первое разрешение отклонено")
}
// Клиент подключился: Hysteria теперь видит одно устройство.
if !reserveDeviceSlot(admissionAuthID, 1, 2, now) {
t.Fatal("проявившееся разрешение посчитано дважды")
}
// Теперь занято: одно подключение плюс одно выданное разрешение.
if reserveDeviceSlot(admissionAuthID, 1, 2, now) {
t.Fatal("выдано разрешение сверх лимита")
}
}
// Разрешение, за которым не последовало подключения, освобождает место само.
func TestReserveDeviceSlotExpiresPendingAdmission(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 0, 1, now) {
t.Fatal("первое разрешение отклонено")
}
if reserveDeviceSlot(admissionAuthID, 0, 1, now) {
t.Fatal("выдано разрешение сверх лимита 1")
}
// Клиент так и не подключился.
later := now.Add(pendingAdmissionTTL + time.Second)
if !reserveDeviceSlot(admissionAuthID, 0, 1, later) {
t.Fatal("протухшее разрешение не освободило место")
}
}
// Место не занимается отказом: иначе серия отклонённых попыток удерживала бы
// слоты на всё время TTL.
func TestReserveDeviceSlotDoesNotConsumeSlotOnRefusal(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
for i := 0; i < 5; i++ {
if reserveDeviceSlot(admissionAuthID, 3, 3, now) {
t.Fatal("выдано разрешение при исчерпанном лимите")
}
}
// Одно устройство отключилось — место обязано быть свободно немедленно.
if !reserveDeviceSlot(admissionAuthID, 2, 3, now) {
t.Fatal("отклонённые попытки заняли места")
}
}
// admissionEntries — размер учёта.
func admissionEntries() int {
deviceAdmissions.Lock()
defer deviceAdmissions.Unlock()
return len(deviceAdmissions.byAuthID)
}
// Учёт не растёт от повторных обращений: одна запись на пира, сколько бы
// попыток он ни сделал.
//
// Границы роста здесь две, и обе существенны. Верхняя — число пиров: сюда
// попадают только authId, прошедшие поиск по secret_digest и всю политику
// доступа, поэтому произвольный ключ извне добавить нельзя. Нижняя — запись
// исчезает, как только помнить о пире нечего (см. следующий тест).
func TestReserveDeviceSlotKeepsOneEntryPerPeer(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
for i := 0; i < 100; i++ {
reserveDeviceSlot("transient-auth", 1, 1, now)
}
if got := admissionEntries(); got != 1 {
t.Fatalf("повторные попытки одного пира дали %d записей", got)
}
}
// Запись исчезает, когда о пире нечего помнить: он не онлайн и выданных
// разрешений за ним нет. Без этого карта накапливала бы по строке на каждый
// когда-либо авторизовавшийся authId, включая давно удалённых пиров.
func TestReserveDeviceSlotForgetsIdlePeer(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 1, 3, now) {
t.Fatal("разрешение отклонено при свободном месте")
}
if admissionEntries() != 1 {
t.Fatal("учёт не запомнил выданное разрешение")
}
// Пир отключился целиком, а выданное разрешение протухло: помнить нечего.
later := now.Add(pendingAdmissionTTL + time.Second)
// Лимит 0 — разрешение не выдаётся, поэтому запись остаться не должна.
reserveDeviceSlot(admissionAuthID, 0, 0, later)
if got := admissionEntries(); got != 0 {
t.Fatalf("запись о неактивном пире осталась: записей %d", got)
}
}
// --- Замок последовательности «/online -> резервация» ------------------------
// Авторизации ОДНОГО пира исключают друг друга: только так снимок, прочитанный
// вторым, не может оказаться старше уже обработанного.
func TestPeerAdmissionGateSerializesSameAuthID(t *testing.T) {
first := lockPeerAdmission("gate-auth")
entered := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
second := lockPeerAdmission("gate-auth")
close(entered)
second()
}()
select {
case <-entered:
t.Fatal("второй вызывающий вошёл в критическую секцию при удерживаемом замке")
case <-time.After(100 * time.Millisecond):
}
first()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("замок не освободился после возврата функции освобождения")
}
}
// Разные пиры не мешают друг другу: замок именно по authId, а не один на
// процесс. Внутри него выполняется сетевой запрос, поэтому общий замок
// выстроил бы подключения всех пиров в одну очередь.
func TestPeerAdmissionGateDoesNotSerializeDifferentAuthIDs(t *testing.T) {
held := lockPeerAdmission("gate-alpha")
defer held()
done := make(chan struct{})
go func() {
defer close(done)
other := lockPeerAdmission("gate-bravo")
other()
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("замок одного пира задержал авторизацию другого")
}
}
// Карта замков не растёт: запись живёт ровно столько, сколько есть желающие её
// взять. Без счётчика ссылок здесь появлялась бы строка на каждый когда-либо
// авторизовавшийся authId — та же утечка, от которой в учёте разрешений
// защищает forgetIfIdle.
func TestPeerAdmissionGateLeavesNoEntriesBehind(t *testing.T) {
if got := heldPeerAdmissionGates(); got != 0 {
t.Fatalf("перед проверкой уже удерживается %d замков", got)
}
for i := 0; i < 50; i++ {
unlock := lockPeerAdmission("gate-transient")
unlock()
}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
unlock := lockPeerAdmission(fmt.Sprintf("gate-%d", idx%3))
unlock()
}(i)
}
wg.Wait()
if got := heldPeerAdmissionGates(); got != 0 {
t.Fatalf("после освобождения осталось %d замков", got)
}
}
// --- Уборка учёта ------------------------------------------------------------
// Запись о пире, за которым не числится ни подключений, ни действующих
// разрешений, убирается.
//
// forgetIfIdle этого не делал: он срабатывает только на ветке отказа, а после
// успешной выдачи разрешения запись оставалась с непустым pending, и снять её
// было некому. В карте накапливались удалённые пиры и старые authId,
// переписанные импортом.
func TestSweepDeviceAdmissionsForgetsIdlePeers(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 0, 3, now) {
t.Fatal("разрешение отклонено при свободном месте")
}
if admissionEntries() != 1 {
t.Fatal("учёт не запомнил выданное разрешение")
}
// Разрешение ещё действует — запись обязана остаться, даже если пира нет в
// `/online`: подключение может проявиться в любой момент.
sweepDeviceAdmissions(map[string]int64{}, now)
if admissionEntries() != 1 {
t.Fatal("уборка сняла действующее разрешение")
}
// Разрешение протухло, подключений нет: помнить нечего.
sweepDeviceAdmissions(map[string]int64{}, now.Add(pendingAdmissionTTL+time.Second))
if got := admissionEntries(); got != 0 {
t.Fatalf("запись о неактивном пире осталась: записей %d", got)
}
}
// Пир, который сейчас на связи, из учёта не убирается: его состояние ещё
// участвует в решениях.
func TestSweepDeviceAdmissionsKeepsOnlinePeers(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
now := time.Now()
if !reserveDeviceSlot(admissionAuthID, 1, 3, now) {
t.Fatal("разрешение отклонено при свободном месте")
}
sweepDeviceAdmissions(
map[string]int64{admissionAuthID: 1},
now.Add(pendingAdmissionTTL+time.Second),
)
if got := admissionEntries(); got != 1 {
t.Fatalf("уборка сняла запись подключённого пира: записей %d", got)
}
}
// Учёт выдерживает параллельный доступ и не выдаёт больше мест, чем есть.
// Проверка ловит дефект и без детектора гонок: он виден по числу разрешений.
func TestReserveDeviceSlotIsConcurrencySafe(t *testing.T) {
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
const workers = 50
const limit = int64(7)
now := time.Now()
var wg sync.WaitGroup
var mu sync.Mutex
allowed := 0
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if reserveDeviceSlot(admissionAuthID, 0, limit, now) {
mu.Lock()
allowed++
mu.Unlock()
}
}()
}
wg.Wait()
if int64(allowed) != limit {
t.Fatalf("выдано %d разрешений при лимите %d", allowed, limit)
}
}
@@ -79,6 +79,11 @@ func TestCreatePeerRefusesReservedName(t *testing.T) {
// оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту. // оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту.
func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) { func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) {
newTestDB(t) newTestDB(t)
// Отключение пира — это ещё и разрыв его активных сессий, поэтому Traffic
// Stats API обязан отвечать. Без него операция завершилась бы частичным
// результатом (запись применена, сессия не разорвана), и тест доказывал бы
// не то, ради чего написан.
startTrafficStats(t, nil)
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
disabled := int64(1) disabled := int64(1)
@@ -97,6 +102,10 @@ func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) {
// иметь возможность его отозвать. Расхождения база/файл удаление не создаёт. // иметь возможность его отозвать. Расхождения база/файл удаление не создаёт.
func TestDeletePeerAllowsRemovingBootstrapPeer(t *testing.T) { func TestDeletePeerAllowsRemovingBootstrapPeer(t *testing.T) {
newTestDB(t) newTestDB(t)
// Удаление — это ещё и разрыв активных сессий по auth ID, который вместе со
// строкой исчезнет. Без отвечающего Traffic Stats API операция завершилась
// бы частичным результатом и строка осталась бы на месте.
startTrafficStats(t, nil)
id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
if err := DeletePeer(id); err != nil { if err := DeletePeer(id); err != nil {

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