From b99be7d514c522084e6dd3751ae2e6a9e2138f0e Mon Sep 17 00:00:00 2001 From: Crimson Date: Sat, 29 Aug 2026 21:37:38 +0500 Subject: [PATCH] =?UTF-8?q?fix(v1):=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D1=83,=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB=20cron=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D1=8B=20=D1=83=D1=82=D0=B5=D1=87=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сборка не собиралась: два контракта приёмки роняли её на корректном коде. verify_api_namespace_contract искал возвращение legacy-пространства имён через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до резолва Hysteria. За ним прятался второй такой же: проверка транзакционности импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt. Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники code_without_comments и code_mentions_in, а отсутствие legacy-маршрута доказывает тест на таблице маршрутов собранного роутера. Планировщик стал собственностью процесса. InitCron вызывался из runServer и на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop() не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка входа крутила for { runServer() } — и каждая правка добавляла целый дублирующий набор джоб, а старое расписание сброса продолжало работать. Фиксированные джобы регистрируются один раз, расписание переносится на месте по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM. Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard), которым его разбирает планировщик: раньше невалидная строка сохранялась, API отвечал успехом, а сброс трафика молча исчезал. updateConfigs стал атомарным: полная проверка партии, одна транзакция, применение к рантайму. Прежний тест ставил запрещённый ключ первым и не смотрел в базу — поймать частичное применение он был неспособен. Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE, HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира. Безопасность: - bootstrap-пароль администратора больше не генерируется и не пишется в журнал, который отдаётся кнопкой выгрузки; отсутствие env — отказ старта; - собственный журнал админки санитизируется наравне с чужим; - golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и достижима с неаутентифицированного запроса; набор алгоритмов подписи зафиксирован через WithValidMethods; - удалён вход по несолёному SHA-224 из предыдущего поколения; - убран modulo bias в util.RandomString — единственном генераторе секретов; - пир установщика защищён во всех путях записи, а не только в импорте; - удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo, проверявшая меньше, чем middleware. Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib, попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг проверки зависимостей (govulncheck + pnpm audit) с записью результата в metadata пакета. --- CHANGELOG.md | 192 ++- README.md | 18 +- apps/cmd/cmd.go | 17 +- apps/cmd/reset.go | 17 +- apps/cmd/server.go | 52 +- apps/controller/config.go | 79 +- apps/controller/config_test.go | 161 +- apps/controller/log.go | 41 +- apps/dao/config.go | 102 +- apps/dao/sqlite.go | 215 ++- apps/frontend/package.json | 10 +- apps/frontend/pnpm-lock.yaml | 289 +++- apps/frontend/src/lang/package/en.ts | 9 +- apps/frontend/src/lang/package/ru.ts | 11 +- apps/frontend/src/types/auto-imports.d.ts | 1386 ++++++----------- apps/frontend/src/types/components.d.ts | 108 +- apps/frontend/src/views/config/list/index.vue | 53 +- .../src/views/hysteria/list/index.vue | 40 +- apps/go.mod | 84 +- apps/go.sum | 231 +-- apps/middleware/cron.go | 44 - apps/model/constant/config.go | 36 +- apps/model/constant/config_test.go | 37 +- apps/service/admin_user.go | 36 +- apps/service/config.go | 77 +- apps/service/cron_scheduler.go | 244 +++ apps/service/cron_scheduler_test.go | 197 +++ apps/service/hysteria2.go | 21 +- apps/service/hysteria2_api.go | 30 +- apps/service/hysteria2_export.go | 17 +- apps/service/journal.go | 18 + apps/service/jwt.go | 123 +- apps/service/jwt_test.go | 204 +++ apps/service/peer.go | 75 + apps/service/peer_bootstrap_guard_test.go | 108 ++ apps/service/peer_secret.go | 64 +- apps/util/encrypt.go | 40 +- apps/util/encrypt_test.go | 99 +- apps/util/rand.go | 52 +- apps/util/rand_test.go | 93 ++ docs/02-build-layer-and-package.md | 48 +- docs/04-admin-panel.md | 153 +- docs/11-testing-and-acceptance.md | 119 +- docs/12-operations-and-troubleshooting.md | 60 + tools/build/build.sh | 9 + tools/build/lib/acceptance.sh | 206 ++- tools/build/lib/package.sh | 2 + tools/build/lib/security.sh | 130 ++ tools/build/lib/versions.sh | 29 +- versions.env | 43 +- 50 files changed, 3907 insertions(+), 1622 deletions(-) delete mode 100644 apps/middleware/cron.go create mode 100644 apps/service/cron_scheduler.go create mode 100644 apps/service/cron_scheduler_test.go create mode 100644 apps/service/jwt_test.go create mode 100644 apps/service/peer_bootstrap_guard_test.go create mode 100644 apps/util/rand_test.go create mode 100644 tools/build/lib/security.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa6798..624587b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,197 @@ Hardening-проход перед релизом `1.0.0`. Основная те «только чистая установка» настоящим системным инвариантом, а не строчкой в документации. -### Исправлено +Второй проход закрывает то, что осталось: два контракта приёмки, гарантированно +ронявшие сборку на корректном коде; жизненный цикл планировщика; мёртвое +состояние в таблице `config`; каналы утечки bootstrap-учётных данных; возраст +графа зависимостей. + +### Исправлено — сборка не собиралась + +- **`build.sh` детектировал сам себя и падал шестым шагом из четырнадцати.** + `verify_api_namespace_contract` искал возвращение legacy-пространства имён + через `grep -rlF '/hui'` по списку каталогов, в который входили `tools/build` + и тесты. Поиск находил два файла: `apps/router/router_test.go`, который + ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать отсутствие маршрута, и сам + `versions.sh`, где эта строка стоит в тексте проверки. То есть добавление + теста, закрепляющего очистку, ломало сборку, а до резолва Hysteria дело не + доходило вовсе. + + Скан теперь идёт только по runtime production sources и по тому, что уезжает + в пакет, с исключением `*_test.go`. Гарантия не ослабла, а переехала на слой, + где она сильнее: отсутствие маршрута доказывает + `TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера, и + существование этого теста само стало частью контракта. + +- **Второй такой же контракт прятался за первым.** Проверка «импорт пиров не + выходит за транзакцию» брала `source.slice(start)` — файл от начала + `applyPeerImportEntry` и до конца, — захватывая объявленные ниже + `ExistPeerName` и `UpdatePeerLastConnectionAt`. Это обычные операции вне + импорта, которым глобальное соединение положено, поэтому проверка падала на + корректном коде. Замечена не была только потому, что сборка до неё не + доходила. Границей тела функции теперь служит следующее объявление верхнего + уровня. + + Отсюда общее правило и помощники `code_without_comments` / `code_mentions_in` + в `acceptance.sh`: приёмка проверяет **код**, а не упоминания. + +### Исправлено — runtime + +- **Смена расписания сброса трафика размножала планировщики.** + `middleware.InitCron()` вызывался из `runServer` и на каждом вызове создавал + новый `cron.New()`, нигде не сохраняя ссылку; `cron.Stop()` не вызывался + нигде. При этом смена `RESET_TRAFFIC_CRON` выполняла `StopServer()`, а точка + входа крутила `for { runServer() }` и поднимала сервис заново. + + Каждая правка добавляла **целый дублирующий набор джоб** — учёт трафика, сбор + метрик, уборка статистики, — а старое расписание сброса продолжало работать. + После двух правок на процессе висели три планировщика и три разных расписания + одновременно. Плюс окно, в котором джобы старого планировщика били в уже + закрытое SQLite-соединение: `releaseResource()` отрабатывал раньше, чем + следующий `runServer` успевал открыть базу. + + Планировщик теперь принадлежит процессу: фиксированные джобы регистрируются + один раз, расписание сброса переносится на месте по своему `EntryID`, + HTTP-сервер к смене настройки отношения не имеет. Цикл перезапуска в точке + входа удалён — перезапуском упавшего юнита занимается systemd. + +- **Невалидное cron-выражение принималось API и молча отключало сброс трафика.** + Поле в панели — `el-select` с `allow-create`, то есть строка произвольная; + backend принимал её как строку до 128 символов, а ошибка `AddFunc` при + следующем старте только логировалась. Оператор получал успех, панель работала, + автоматический сброс исчезал. + + Выражение проверяется до записи в базу тем же парсером (`cron.ParseStandard`), + которым его потом разбирает планировщик. Невалидное значение — отказ, база не + меняется. Пустое значение легально и означает «сброс выключен». + +- **`updateConfigs` применял партию частично.** Валидация и запись шли в одном + цикле, поэтому партия «разрешённый ключ + запрещённый» применяла первый и + возвращала ошибку на втором. Существовавший тест ставил запрещённый ключ + первым и не смотрел в базу — поймать это он был неспособен по построению. + + Теперь: полная проверка партии → одна транзакция (`dao.WithConfigTx`) → + применение к рантайму. Тест переписан на обратный порядок ключей и проверяет + состояние базы на настоящей SQLite. + +- **Сервис не завершался штатно.** `SIGTERM` от systemd убивал процесс на + середине: соединения обрывались, SQLite закрывался вместе с процессом, джобы + могли быть остановлены посреди записи. Добавлено штатное завершение — + планировщик глушится и дожидается запущенных джоб, затем закрывается база. + +- **Ложные ERROR в журнале на каждой первой загрузке.** Создание секретов шло по + схеме «сначала INSERT, при ошибке UPDATE», а строки ключей уже существовали из + `seedBaseConfig`: три записи `duplicated key not allowed` уровня error на + совершенно здоровом старте. Зеркальная схема «сначала UPDATE, при ошибке + INSERT» в других местах была хуже — она **тихо не делала ничего**, если строки + не было: UPDATE без совпавших строк не ошибка, поэтому ветка INSERT не + выполнялась, а вызывающий получал сгенерированный секрет как сохранённый. Для + `JWT_SECRET` это означало бы подпись токенов ключом, которого нет в базе. + Обе схемы заменены на `dao.UpsertConfigValue`, решающий по `RowsAffected`. + +- **Дублирующая реализация генерации ключей шифрования.** В + `service/peer_secret.go` лежали построчные копии `getOrCreateConfigKey` и + `getPeerSecretEncryptionKey` из `dao/sqlite.go`: две функции в двух пакетах, + порождающие один и тот же материал шифрования. Расхождение между ними + означало бы, что секреты пиров шифруются одним ключом, а расшифровываются + другим. Осталась одна реализация в `dao`. + +### Безопасность + +- **Bootstrap-пароль администратора писался в журнал открытым текстом.** При + отсутствии `HY2XS_ADMIN_INITIAL_PASSWORD` админка генерировала пароль сама и + печатала его двумя `logrus.Warnf` в `/var/log/hy2xs/hy2xs-admin.log` — файл, + который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Такой пароль + к тому же не знал никто, кроме журнала. + + Отсутствие переменной теперь **отказ старта** с объяснением причины. То же для + `HY2XS_ADMIN_CON_PASS` при создании пира установщика: его секрет продублирован + в `/etc/hy2xs/bootstrap-admin.secret`, откуда его читает проверка machine-auth, + и придуманный админкой секрет разошёлся бы с файлом. + +- **Собственный журнал админки выгружался без санитайза**, хотя чужой (журнал + Hysteria) — с санитайзом. Теперь оба проходят `SanitizeLogText`, и во вкладке + просмотра тоже. + +- **`golang-jwt/jwt` v3 в пути аутентификации.** У v3.2.2 есть GO-2025-3553, + у которой **нет исправленной версии в ветке v3** (`Fixed in: N/A`), а уязвимый + код достигается из `ParseToken`, то есть с неаутентифицированного запроса. + Выполнен переход на `jwt/v5`. + + Заодно закрыт тихий недостаток: `keyfunc` возвращал ключ, не проверяя алгоритм + подписи, — набор допустимых алгоритмов фактически задавал сам токен. Разбор + ограничен `jwt.WithValidMethods`, проверяются `issuer` и обязательное наличие + срока жизни; пустой `JWT_SECRET` считается повреждённым состоянием, а не + ключом нулевой длины. + +- **Вход по несолёному SHA-224 больше невозможен.** `VerifyPassword` принимала + такой хеш как «legacy»-формат предыдущего поколения. В v1 он недостижим: + миграции таблицы `account` удалены, установка возможна только на чистый хост, + конфигурация 0.x отклоняется по схеме. Compatibility-ветка пережила слой + совместимости, ради которого существовала, и осталась запасным путём проверки + пароля слабым алгоритмом в обработчике логина. + +- **Modulo bias в генераторе секретов.** `util.RandomString` брала остаток байта + от деления на длину алфавита (62): первые восемь символов выпадали примерно на + четверть чаще остальных. Через эту функцию проходят `JWT_SECRET`, + `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY`, секрет trafficStats API, + секреты и `auth_id` пиров. Добавлена отбраковка (rejection sampling). + +- **Пир установщика был защищён только в импорте.** Обычный CRUD панели + позволял переподписать или переименовать `bootstrap-admin-peer`, молча + рассинхронизировав базу с `/etc/hy2xs/bootstrap-admin.secret`. Защита + распространена на все пути записи; удаление и отключение остаются + разрешёнными — это осознанные действия оператора, не создающие расхождения. + +- **Латентная паника в разборе токена.** `service.GetToken` доставала токен + через `strings.SplitN(header, " ", 2)[1]` и падала на заголовке без пробела. + Единственный потребитель — резервная ветка `GetAdminInfo`, недостижимая и + проверявшая меньше, чем middleware (ни статус учётной записи, ни версию + токена). Оба удалены: разбор токена у продукта ровно один. + +- **`reset-admin` генерировал 6-символьные логин и пароль** — нижняя граница, + которую пропускает `HashPassword`. Увеличено до 12 и 24. + +### Изменено + +- **Toolchain переведён на поддерживаемые линии.** `GO_VERSION` 1.21.13 → 1.26.7, + `NODE_VERSION` 20.19.0 (EOL) → 24.20.0. Go компилирует `hy2xs-admin`, поэтому + его stdlib целиком попадает в production-бинарь: на прежнем графе + `govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в stdlib. + После перехода и обновления зависимостей — **ноль**. + + Bun намеренно оставлен на 1.3.13: оркестратор собирается через + `bun build --compile`, то есть Bun runtime входит в исполняемый файл, и смена + его версии требует отдельного прохода по всей матрице проверок. + +- **Добавлен обязательный шаг проверки зависимостей** (`tools/build/lib/security.sh`): + `govulncheck ./...` для Go-графа и stdlib с анализом достижимости и + `pnpm audit --prod` для frontend. Версия `govulncheck` пиньтся в `versions.env`, + база уязвимостей подтягивается на каждом запуске. Аварийный выход — + `ALLOW_VULNERABLE_DEPENDENCIES=true`; результат уезжает в metadata пакета полем + `dependency_security_gate`. + +- **Обновлены зависимости frontend, попадающие в браузерный бандл**: `axios` + 1.3.4 → 1.20.0, плюс `lodash`/`lodash-es` через `pnpm.overrides` до 4.18.1. + Прямые зависимости и их диапазоны не менялись — двинулся только lockfile. + В production-графе не осталось уязвимостей уровня high и critical. + +### Удалено + +- **Четыре ключа таблицы `config` без единого потребителя** — + `HYSTERIA2_ENABLE` (жизненным циклом Hysteria владеет systemd), + `HYSTERIA2_CONFIG` (второй источник истины рядом с + `/etc/hysteria/config.yaml`, причём читался **первым**), + `HYSTERIA2_TRAFFIC_TIME` (настройка «период учёта трафика», которую не читал + никто: интервал сбора метрик задан в коде) и `HYSTERIA2_CONFIG_REMARK` (пустая + read-only строка). Строки удаляются миграцией `006_drop_dead_config_keys`. + + Настоящую замену получил только последний: имя профиля в клиентской ссылке + теперь выводится из имени пира, а при его отсутствии — из публичного хоста. + + После очистки панель владеет ровно одной настройкой — `RESET_TRAFFIC_CRON`. + +### Исправлено — предыдущий проход - **Каждая чистая установка падала сразу после `apt-get`.** Внутри `install` `preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста. diff --git a/README.md b/README.md index 4cec421..cf91606 100644 --- a/README.md +++ b/README.md @@ -920,9 +920,10 @@ export GITHUB_TOKEN= 4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; 5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; 6. прогоняет `go vet` и `go test` для HY2XS admin; -7. формирует архив и прогоняет acceptance‑проверки. +7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit --prod`); +8. формирует архив и прогоняет acceptance‑проверки. -Любой сбой на шагах 1–6 останавливает сборку до создания пакета. +Любой сбой на шагах 1–7 останавливает сборку до создания пакета. Переменные, управляющие выбором версии Hysteria: @@ -934,6 +935,19 @@ export GITHUB_TOKEN= | `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass | | `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл | +Переменные проверки зависимостей: + +| Переменная | По умолчанию | Назначение | +| --- | --- | --- | +| `ALLOW_VULNERABLE_DEPENDENCIES` | `false` | Выпустить релиз, зная о найденной уязвимости. Решение человека, а не поведение по умолчанию; попадает в metadata как `dependency_security_gate=accepted-risk` | +| `SKIP_SECURITY_SCAN` | `false` | Полностью пропустить шаг; в metadata пишется `skipped` | + +Проверка versions‑контракта и проверка зависимостей отвечают на разные вопросы. +Первая следит, что зафиксированные версии **согласованы между собой**; вторая — +что про эти версии **не стало известно плохого**. Зафиксированный граф не +стареет только на бумаге: advisory по нему выходят и после фиксации, а сборка +релиза — единственный момент, когда это расхождение ловится дёшево. + Полный E2E с реальным клиентом Hysteria запускается отдельно (нужен Go: ссылка берётся из production‑генератора, а не из отдельной реализации внутри теста): diff --git a/apps/cmd/cmd.go b/apps/cmd/cmd.go index cb90da0..f98e5c1 100644 --- a/apps/cmd/cmd.go +++ b/apps/cmd/cmd.go @@ -35,11 +35,18 @@ func run(cmd *cobra.Command, args []string) { fmt.Println(err.Error()) os.Exit(1) } - for { - if err := runServer(port); err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } + // Цикла `for { runServer() }` здесь больше нет. + // + // Он существовал ради единственного сценария: смена RESET_TRAFFIC_CRON + // делала StopServer(), runServer возвращался, и цикл поднимал сервис + // заново, чтобы новое расписание попало в новый планировщик. Это был + // внутрипроцессный «перезапуск», из-за которого планировщики накапливались + // (см. service/cron_scheduler.go). Расписание теперь переносится на месте, + // перезапускать сервер незачем, а перезапуском упавшего юнита занимается + // systemd (Restart=on-failure) — он для этого и предназначен. + if err := runServer(port); err != nil { + fmt.Println(err.Error()) + os.Exit(1) } } diff --git a/apps/cmd/reset.go b/apps/cmd/reset.go index 875ef1c..4f1df02 100644 --- a/apps/cmd/reset.go +++ b/apps/cmd/reset.go @@ -21,13 +21,26 @@ func init() { rootCmd.AddCommand(resetCmd) } +// Длины восстановительных учётных данных. +// +// Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36 +// бит, то есть ровно нижняя граница, которую пропускает HashPassword, и +// единственной защитой оставался force_password_change. Пароль восстановления +// живёт до первого входа администратора, но до этого входа он полноценно +// пускает в панель, поэтому его стойкость должна соответствовать остальным +// секретам продукта, а не минимально допустимой. +const ( + resetUsernameLength = 12 + resetPasswordLength = 24 +) + func runReset(cmd *cobra.Command, args []string) { - username, err := util.RandomString(6) + username, err := util.RandomString(resetUsernameLength) if err != nil { fmt.Println(err.Error()) os.Exit(1) } - password, err := util.RandomString(6) + password, err := util.RandomString(resetPasswordLength) if err != nil { fmt.Println(err.Error()) os.Exit(1) diff --git a/apps/cmd/server.go b/apps/cmd/server.go index 20c68da..504b11b 100644 --- a/apps/cmd/server.go +++ b/apps/cmd/server.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "github.com/gin-gonic/gin" @@ -14,9 +15,11 @@ import ( "net" "net/http" "os" + "os/signal" "path/filepath" "strconv" "strings" + "syscall" ) func resolveUiBindHost() (string, error) { @@ -69,9 +72,14 @@ func runServer(port string) error { if err := dao.InitSql(); err != nil { return err } - if err := middleware.InitCron(); err != nil { + if err := service.InitCron(); err != nil { return err } + // Порядок остановки обратный порядку регистрации: планировщик глушится + // РАНЬШЕ, чем releaseResource закроет SQLite. Каждая джоба ходит в базу, + // и обратный порядок означал бы гарантированные «database is closed» на + // каждом штатном завершении сервиса. + defer service.StopCron() if err := service.InitHysteria2(); err != nil { return err } @@ -97,19 +105,49 @@ func runServer(port string) error { // loopback и публикуется через SSH-туннель или reverse proxy, на котором // TLS и заканчивается. service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r) - if err := service.StartServer(); err != nil && err != http.ErrServerClosed { - logrus.Errorf("start server err: %v", err) - return errors.New("start server err") + + // Штатное завершение по сигналу. + // + // Раньше SIGTERM от systemd просто убивал процесс: соединения обрывались + // на середине, SQLite закрывался не через defer, а вместе с процессом, и + // джобы планировщика могли быть остановлены посреди записи. Для сервиса, + // который ведёт учёт трафика в транзакциях, это не абстракция — обычный + // `systemctl restart` попадал в это окно. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + serverErr := make(chan error, 1) + go func() { + serverErr <- service.StartServer() + }() + + select { + case err := <-serverErr: + return classifyServeError(err) + case <-ctx.Done(): + logrus.Infof("shutdown signal received, stopping hy2xs-admin") + if err := service.StopServer(); err != nil { + return err + } + return classifyServeError(<-serverErr) } - return nil +} + +// classifyServeError отделяет штатное закрытие сервера от настоящего отказа. +func classifyServeError(err error) error { + if err == nil || errors.Is(err, http.ErrServerClosed) { + return nil + } + logrus.Errorf("start server err: %v", err) + return errors.New("start server err") } func releaseResource() { if err := dao.CloseSqliteDB(); err != nil { - logrus.Errorf(err.Error()) + logrus.Errorf("%v", err) } if err := service.ReleaseHysteria2(); err != nil { - logrus.Errorf(err.Error()) + logrus.Errorf("%v", err) } } diff --git a/apps/controller/config.go b/apps/controller/config.go index 739ef66..6171800 100644 --- a/apps/controller/config.go +++ b/apps/controller/config.go @@ -39,13 +39,31 @@ func denyUnknownConfigKey(key string, allowed []string, operation string, c *gin ) } +// UpdateConfigs применяет партию настроек по принципу «всё или ничего». +// +// Три прохода, и каждый отвечает за своё: +// +// 1. проверка партии целиком — права на ключ, дубликаты, значения; +// 2. одна транзакция базы; +// 3. применение к рантайму. +// +// Раньше проходов не было вовсе: цикл проверял очередной элемент и тут же его +// записывал. Партия «разрешённый ключ + запрещённый» применяла первый и +// возвращала ошибку на втором — оператор получал отказ на запрос, который +// систему уже изменил. +// +// Второе изменение того же места: смена расписания больше не роняет +// HTTP-сервер. Раньше здесь стоял `go service.StopServer()`, а точка входа +// крутила runServer в цикле, из-за чего на процессе накапливались планировщики +// (см. service/cron_scheduler.go). func UpdateConfigs(c *gin.Context) { configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{}) if err != nil { return } - needRestart := false + updates := make([]service.ConfigUpdate, 0, len(configsUpdateDto.ConfigUpdateDtos)) + seen := make(map[string]struct{}, len(configsUpdateDto.ConfigUpdateDtos)) for _, item := range configsUpdateDto.ConfigUpdateDtos { key := *item.Key @@ -63,42 +81,67 @@ func UpdateConfigs(c *gin.Context) { return } - if key == constant.ResetTrafficCron { - resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron) - if err != nil { - vo.Fail(err.Error(), c) - return - } - if *resetTrafficCron.Value != value { - needRestart = true - } + // Один ключ дважды в одной партии — неоднозначный запрос: какое из + // двух значений считать намерением оператора, определить нельзя. + if _, duplicate := seen[key]; duplicate { + vo.Fail(fmt.Sprintf("config key %q appears more than once in the batch", key), c) + return } + seen[key] = struct{}{} - if err = service.UpdateConfig(key, value); err != nil { + if err = service.ValidateConfigValue(key, value); err != nil { vo.Fail(err.Error(), c) return } + + updates = append(updates, service.ConfigUpdate{Key: key, Value: value}) } - if needRestart { - go func() { - _ = service.StopServer() - }() + if err = service.UpdateConfigs(updates); err != nil { + vo.Fail(err.Error(), c) + return + } + + if err = applyRuntimeConfigUpdates(updates); err != nil { + vo.Fail(err.Error(), c) + return } vo.Success(nil, c) } +// applyRuntimeConfigUpdates доносит уже сохранённые настройки до рантайма. +// +// Отказ здесь недостижим по построению: значения проверены тем же парсером, +// которым планировщик их разбирает, а сам планировщик поднимается в runServer +// до старта HTTP-сервера — то есть к моменту обработки запроса он всегда +// запущен. Проверка оставлена именно поэтому: если этот инвариант когда-нибудь +// сломают, отказ должен быть громким, а не молча пропавшей джобой. +func applyRuntimeConfigUpdates(updates []service.ConfigUpdate) error { + for _, item := range updates { + if item.Key != constant.ResetTrafficCron { + continue + } + if err := service.RescheduleResetTraffic(item.Value); err != nil { + return err + } + } + return nil +} + // isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор. // // Формально они и так не входят в allowlist, но отказ по ним обязан объяснять // ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого // ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`. +// +// HYSTERIA2_ENABLE и HYSTERIA2_CONFIG отсюда убраны вместе с самими ключами: +// первым никто не управлял, второй был вторым источником истины рядом с +// /etc/hysteria/config.yaml. Serverный конфиг по-прежнему принадлежит +// оркестратору — просто теперь он живёт только в файле, а не ещё и в SQLite. func isOrchestratorManagedConfigKey(key string) bool { switch key { - case constant.Hysteria2Enable, - constant.Hysteria2Config, - constant.Hysteria2TrafficStatsSecret: + case constant.Hysteria2TrafficStatsSecret: return true default: return false diff --git a/apps/controller/config_test.go b/apps/controller/config_test.go index d3e23ce..d221305 100644 --- a/apps/controller/config_test.go +++ b/apps/controller/config_test.go @@ -5,11 +5,14 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" "github.com/gin-gonic/gin" + "hy2xs-admin/dao" "hy2xs-admin/model/constant" + "hy2xs-admin/service" ) // Секреты, которые лежат в таблице `config` рядом с пользовательскими @@ -72,7 +75,7 @@ func TestListConfigRefusesInternalKeys(t *testing.T) { // Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос. func TestListConfigRefusesMixedBatch(t *testing.T) { result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{ - "keys": []string{constant.Hysteria2TrafficTime, constant.PeerSecretEncryptionKey}, + "keys": []string{constant.ResetTrafficCron, constant.PeerSecretEncryptionKey}, }) if result.Type != "no" { @@ -109,8 +112,6 @@ func TestUpdateConfigsRefusesInternalKeys(t *testing.T) { // такого ключа»: оператор должен понять, что менять их надо через reconfigure. func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) { for _, key := range []string{ - constant.Hysteria2Enable, - constant.Hysteria2Config, constant.Hysteria2TrafficStatsSecret, } { result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ @@ -126,6 +127,23 @@ func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) { } } +// Удалённые ключи обязаны отклоняться как неизвестные, а не приниматься. +func TestUpdateConfigsRefusesRemovedKeys(t *testing.T) { + for _, key := range []string{ + "HYSTERIA2_ENABLE", + "HYSTERIA2_CONFIG", + "HYSTERIA2_TRAFFIC_TIME", + "HYSTERIA2_CONFIG_REMARK", + } { + result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ + "configUpdateDtos": []map[string]string{{"key": key, "value": "x"}}, + }) + if result.Type != "no" { + t.Errorf("удалённый ключ %s принят на запись: %+v", key, result) + } + } +} + func TestUpdateConfigsRefusesUnknownKey(t *testing.T) { result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}}, @@ -139,17 +157,146 @@ func TestUpdateConfigsRefusesUnknownKey(t *testing.T) { } } -// Отказ на любой записи партии обязан отменить партию целиком: иначе первые -// ключи применились бы, а оператор получил бы ошибку. -func TestUpdateConfigsRefusesWholeBatchOnFirstForbiddenKey(t *testing.T) { +// newConfigTestDB поднимает настоящую SQLite: проверка «партия не применилась +// частично» без базы бессмысленна — именно состояние базы и есть предмет +// утверждения. Схема поднимается отдельно от InitSql: bootstrap-администратор +// и bootstrap-пир здесь не нужны. +func newConfigTestDB(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() + }) +} + +func storedConfigValue(t *testing.T, key string) string { + t.Helper() + + config, err := dao.GetConfig("key = ?", key) + if err != nil { + t.Fatalf("не удалось прочитать %s: %v", key, err) + } + if config.Value == nil { + return "" + } + return *config.Value +} + +// Отказ на любой записи партии обязан отменить партию ЦЕЛИКОМ. +// +// Разрешённый ключ стоит ПЕРВЫМ намеренно. Предыдущая версия этого теста +// ставила запрещённый ключ первым, поэтому проходила и на реализации, которая +// проверяла и записывала настройки в одном цикле: до второго элемента дело не +// доходило. Поймать частичное применение такой порядок неспособен по +// построению, и состояние базы тест тоже не смотрел. +func TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden(t *testing.T) { + newConfigTestDB(t) + + before := storedConfigValue(t, constant.ResetTrafficCron) + result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ "configUpdateDtos": []map[string]string{ + {"key": constant.ResetTrafficCron, "value": "@weekly"}, {"key": constant.JwtSecret, "value": "attacker-controlled"}, - {"key": constant.Hysteria2TrafficTime, "value": "10"}, }, }) if result.Type != "no" { t.Fatalf("партия с секретом не отклонена: %+v", result) } + if after := storedConfigValue(t, constant.ResetTrafficCron); after != before { + t.Fatalf("отклонённая партия изменила %s: было %q, стало %q", constant.ResetTrafficCron, before, after) + } + if secret := storedConfigValue(t, constant.JwtSecret); secret == "attacker-controlled" { + t.Fatal("подписывающий ключ перезаписан отклонённой партией") + } +} + +// Успешная партия обязана и сохраниться, и доехать до рантайма — БЕЗ +// перезапуска HTTP-сервера. +// +// Раньше новое расписание применялось единственным способом: обработчик +// вызывал StopServer(), точка входа поднимала runServer заново, и новый +// планировщик читал настройку из базы. Тест закрепляет обе половины замены: +// значение в базе изменилось, джоба перепланирована, число записей +// планировщика не выросло. +func TestUpdateConfigsAppliesScheduleWithoutRestart(t *testing.T) { + newConfigTestDB(t) + + if err := service.InitCron(); err != nil { + t.Fatalf("InitCron: %v", err) + } + t.Cleanup(service.StopCron) + + before := service.CronEntryCount() + + result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ + "configUpdateDtos": []map[string]string{ + {"key": constant.ResetTrafficCron, "value": "@weekly"}, + }, + }) + + if result.Type != "ok" { + t.Fatalf("корректная партия отклонена: %+v", result) + } + if stored := storedConfigValue(t, constant.ResetTrafficCron); stored != "@weekly" { + t.Fatalf("значение не сохранено: %q", stored) + } + if !service.ResetTrafficScheduled() { + t.Fatal("новое расписание не применилось к планировщику") + } + if after := service.CronEntryCount(); after != before { + t.Fatalf("применение расписания добавило джобы: было %d, стало %d", before, after) + } +} + +// Невалидное значение в партии обязано отменить её так же, как запрещённый +// ключ: разница между «нельзя писать сюда» и «нельзя писать это» касается +// только текста ошибки, но не последствий для базы. +func TestUpdateConfigsRefusesWholeBatchOnInvalidValue(t *testing.T) { + newConfigTestDB(t) + + before := storedConfigValue(t, constant.ResetTrafficCron) + + result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ + "configUpdateDtos": []map[string]string{ + {"key": constant.ResetTrafficCron, "value": "какая-нибудь ерунда"}, + }, + }) + + if result.Type != "no" { + t.Fatalf("невалидное cron-выражение принято: %+v", result) + } + if after := storedConfigValue(t, constant.ResetTrafficCron); after != before { + t.Fatalf("невалидное значение попало в базу: было %q, стало %q", before, after) + } +} + +// Один ключ дважды в партии — неоднозначный запрос: какое из двух значений +// считать намерением оператора, определить нельзя. +func TestUpdateConfigsRefusesDuplicateKeys(t *testing.T) { + newConfigTestDB(t) + + before := storedConfigValue(t, constant.ResetTrafficCron) + + result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{ + "configUpdateDtos": []map[string]string{ + {"key": constant.ResetTrafficCron, "value": "@weekly"}, + {"key": constant.ResetTrafficCron, "value": "@monthly"}, + }, + }) + + if result.Type != "no" { + t.Fatalf("партия с дубликатом ключа принята: %+v", result) + } + if after := storedConfigValue(t, constant.ResetTrafficCron); after != before { + t.Fatalf("партия с дубликатом изменила базу: было %q, стало %q", before, after) + } } diff --git a/apps/controller/log.go b/apps/controller/log.go index bd9f9f5..240e7c5 100644 --- a/apps/controller/log.go +++ b/apps/controller/log.go @@ -43,6 +43,14 @@ func LogSystem(c *gin.Context) { vo.Fail("Unable to unmarshal log data", c) continue } + // Собственный журнал санитизируется так же, как чужой. + // + // Раньше через SanitizeLogText проходил только журнал Hysteria: он + // «чужой», а свой мы якобы контролируем. Контроль этот держался на + // внимательности — ровно до logrus.Warnf с паролем администратора в + // dao/sqlite.go. Санитайз здесь стоит не вместо аккуратности в местах + // записи, а на случай следующего такого места. + logSystemVo.Msg = service.SanitizeLogText(logSystemVo.Msg) logSystemVos = append(logSystemVos, logSystemVo) } vo.Success(vo.LogSystemPage[vo.LogSystemVo]{ @@ -80,10 +88,26 @@ func ExportLog(c *gin.Context) { } var fileName string - var filePath string if *logExportDto.Option == 0 { fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405")) - filePath = constant.SystemLogPath + + // Журнал админки отдаётся санитизированным, а не файлом «как есть». + // + // Раньше здесь стоял c.File(constant.SystemLogPath): оператор скачивал + // сырой /var/log/hy2xs/hy2xs-admin.log. Журнал Hysteria при этом + // проходил через SanitizeLogText — то есть чужому журналу продукт не + // доверял, а своему доверял. Асимметрия ничем не обоснована: файл в + // обоих случаях покидает сервер и пересылается в переписке. + output, exportErr := service.ExportAdminLog() + if exportErr != nil { + vo.Fail("log file not exist", c) + return + } + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + c.Data(200, "text/plain; charset=utf-8", []byte(output)) + return } else if *logExportDto.Option == 1 { fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405")) output, exportErr := service.ExportHysteriaJournalLogs(5000) @@ -98,12 +122,9 @@ func ExportLog(c *gin.Context) { return } - if !util.Exists(filePath) { - vo.Fail("log file not exist", c) - return - } - c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Transfer-Encoding", "binary") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) - c.File(filePath) + // Обе ветки выше завершаются return. Сюда попадает только неизвестное + // значение option, и это отказ, а не отдача пустого файла: раньше здесь + // оставался путь с пустым filePath, который сводился к тому же сообщению + // окольной дорогой. + vo.Fail("unsupported log export option", c) } diff --git a/apps/dao/config.go b/apps/dao/config.go index 18533e7..6438a0f 100644 --- a/apps/dao/config.go +++ b/apps/dao/config.go @@ -18,14 +18,100 @@ func SaveConfig(config entity.Config) (int64, error) { } func UpdateConfig(keys []string, updates map[string]interface{}) error { - if len(updates) > 0 { - updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") - if tx := sqliteDB.Model(&entity.Config{}). - Where("key in ?", keys). - Updates(updates); tx.Error != nil { - logrus.Errorf("%v", tx.Error) - return errors.New(constant.SysError) - } + return updateConfigOn(sqliteDB, keys, updates) +} + +// UpsertConfigValue записывает значение ключа, создавая строку, если её нет. +// +// Заменяет две зеркальные самодельные схемы, которые жили в sqlite.go и обе +// были неверны: +// +// 1. «сначала INSERT, при ошибке UPDATE» — работало, но на КАЖДОЙ первой +// загрузке печатало в журнал `duplicated key not allowed` уровня error, по +// одной строке на секрет. Оператор видел три ошибки на совершенно здоровом +// старте и не имел способа отличить их от настоящих; +// +// 2. «сначала UPDATE, при ошибке INSERT» — тихо не делало НИЧЕГО, если строки +// не было. UPDATE без совпавших строк не является ошибкой: gorm возвращает +// nil и RowsAffected = 0, поэтому ветка INSERT не выполнялась, а вызывающий +// получал сгенерированный секрет как успешно сохранённый. Для JWT_SECRET +// это означало бы подпись токенов ключом, которого нет в базе. Спасала +// только предварительная вставка пустых строк в seedBaseConfig — то есть +// корректность держалась на порядке выполнения миграций. +// +// Здесь решение принимается по RowsAffected, а не по наличию ошибки. +func UpsertConfigValue(key string, value string) error { + if sqliteDB == nil { + return errors.New("sqlite is not initialized") + } + + tx := sqliteDB.Model(&entity.Config{}). + Where("key = ?", key). + Updates(map[string]interface{}{ + "value": value, + "update_time": time.Now().Format("2006-01-02 15:04:05"), + }) + if tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) + } + if tx.RowsAffected > 0 { + return nil + } + + remark := key + if _, err := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil { + return err + } + return nil +} + +// ConfigTx — записи в таблицу `config` в границах одной единицы работы. +// +// Интерфейс повторяет PeerTx и по той же причине: транзакция должна быть +// доступна слою service, а *gorm.DB туда протекать не должен. Реализация одна +// и та же для глобального соединения и для транзакции, поэтому «обычный» и +// «транзакционный» пути не могут разойтись в поведении. +type ConfigTx interface { + UpdateConfig(keys []string, updates map[string]interface{}) error +} + +type configTx struct { + db *gorm.DB +} + +// WithConfigTx выполняет fn в одной транзакции: либо применяются все записи +// партии, либо ни одной. +// +// Что было. updateConfigs валидировал и записывал настройки в одном цикле: +// проверил элемент — записал, проверил следующий. Партия из разрешённого +// ключа и запрещённого применяла первый и возвращала ошибку на втором, то есть +// оператор получал отказ на запрос, который систему уже изменил. Тест на этот +// случай существовал, но ставил запрещённый ключ ПЕРВЫМ и не смотрел в базу, +// поэтому поймать частичное применение был неспособен по построению. +func WithConfigTx(fn func(tx ConfigTx) error) error { + if sqliteDB == nil { + return errors.New("sqlite is not initialized") + } + return sqliteDB.Transaction(func(tx *gorm.DB) error { + return fn(configTx{db: tx}) + }) +} + +func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) error { + return updateConfigOn(c.db, keys, updates) +} + +func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") + if tx := db.Model(&entity.Config{}). + Where("key in ?", keys). + Updates(updates); tx.Error != nil { + logrus.Errorf("%v", tx.Error) + return errors.New(constant.SysError) } return nil } diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index f1cbfe5..6fb0883 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -95,6 +95,22 @@ func envBoolAsInt(name string, fallback int) int { return fallback } +// ensureSecureBootstrapAdmin создаёт первую учётную запись администратора. +// +// Пароль приходит ТОЛЬКО из HY2XS_ADMIN_INITIAL_PASSWORD, и его отсутствие — +// отказ старта, а не повод придумать пароль самостоятельно. +// +// Что было. При пустом env админка генерировала пароль сама и печатала его +// двумя logrus.Warnf — то есть открытым текстом в /var/log/hy2xs/hy2xs-admin.log. +// Этот же файл отдаётся оператору кнопкой выгрузки журнала и попадает в +// diagnostics-бандл, который люди пересылают в переписке. Получался постоянный +// канал утечки учётных данных администратора. +// +// Второе, менее очевидное: сгенерированный здесь пароль не знает НИКТО, кроме +// журнала. Оркестратор пишет свой в /etc/hy2xs/bootstrap-admin.secret и в +// hy2xs.env, и именно он считается контрактом установки. Значит, попадание +// сюда означает не «нужно что-то придумать», а повреждённый контракт запуска — +// и правильная реакция на него громкая, а не молчаливая. func ensureSecureBootstrapAdmin() error { adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER")) if adminUser == "" { @@ -105,14 +121,16 @@ func ensureSecureBootstrapAdmin() error { } adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")) - passwordGenerated := false if adminPassword == "" { - password, pwdErr := util.RandomString(18) - if pwdErr != nil { - return pwdErr - } - adminPassword = password - passwordGenerated = true + return errors.New( + "HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" + + "Это признак повреждённого контракта запуска: пароль первого администратора\n" + + "задаёт install-оркестратор через /etc/hy2xs/hy2xs.env, а копию кладёт в\n" + + "/etc/hy2xs/bootstrap-admin.secret. Придумывать его самостоятельно админка не\n" + + "будет: такой пароль не знал бы никто, кроме журнала.\n" + + "Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" + + "`hy2xs-orchestrator repair --allow-partial-state`.", + ) } forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1) status := int64(1) @@ -135,23 +153,22 @@ func ensureSecureBootstrapAdmin() error { if _, saveErr := SaveAdminUser(account); saveErr != nil { return saveErr } - if passwordGenerated { - logrus.Warnf("Initial admin username: %s", adminUser) - logrus.Warnf("Initial admin password: %s", adminPassword) - } return nil } +// ensureSecureBootstrapPeer создаёт пир установщика. +// +// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и +// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё +// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора +// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с +// файлом, и первая же проверка подключения после установки провалилась бы — +// на корректном во всём остальном сервере. +// +// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать +// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса +// без переменной падал бы на ровном месте. func ensureSecureBootstrapPeer() error { - bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS")) - if bootstrapSecret == "" { - generated, err := util.RandomString(24) - if err != nil { - return err - } - bootstrapSecret = generated - } - if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil { return nil } @@ -159,6 +176,19 @@ func ensureSecureBootstrapPeer() error { return nil } + bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS")) + if bootstrapSecret == "" { + return errors.New( + "HY2XS_ADMIN_CON_PASS не задан, а пира установщика bootstrap-admin-peer в базе нет.\n" + + "Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" + + "/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" + + "Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" + + "подключения после установки провалилась бы.\n" + + "Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" + + "`hy2xs-orchestrator repair --allow-partial-state`.", + ) + } + secretDigest, err := buildPeerSecretDigest(bootstrapSecret) if err != nil { return err @@ -205,15 +235,23 @@ func ensureSecureBootstrapPeer() error { } func buildPeerSecretDigest(rawSecret string) (string, error) { - secretKey, err := getOrCreatePeerSecretDigestKey() + secretKey, err := GetOrCreatePeerSecretDigestKey() if err != nil { return "", err } return util.HmacSHA256Hex(rawSecret, secretKey), nil } -func getOrCreatePeerSecretEncryptionKey() ([]byte, error) { - raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32) +// GetOrCreatePeerSecretEncryptionKey возвращает master-key шифрования секретов +// пиров, при необходимости создавая его. +// +// Экспортирована, чтобы у ключа была ровно одна реализация. Раньше идентичная +// копия жила в service/peer_secret.go: две функции с одинаковым именем в разных +// пакетах, обе создающие один и тот же материал шифрования. Расхождение между +// ними означало бы, что секреты пиров шифруются одним ключом, а +// расшифровываются другим, и обнаружилось бы это на живых пирах. +func GetOrCreatePeerSecretEncryptionKey() ([]byte, error) { + raw, err := GetOrCreateConfigSecret(constant.PeerSecretEncryptionKey, 32) if err != nil { return nil, err } @@ -221,41 +259,42 @@ func getOrCreatePeerSecretEncryptionKey() ([]byte, error) { if decErr == nil { return decoded, nil } + + // Ключ, записанный не в base64: приводим к стабильному представлению один + // раз, сохранив исходные байты, — иначе уже зашифрованные секреты пиров + // перестали бы расшифровываться. plain := []byte(strings.TrimSpace(raw)) if len(plain) < 32 { return nil, errors.New("invalid peer secret encryption key") } plain = plain[:32] - encoded := base64.StdEncoding.EncodeToString(plain) - if err := UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); err != nil { + if err := UpsertConfigValue(constant.PeerSecretEncryptionKey, base64.StdEncoding.EncodeToString(plain)); err != nil { return nil, err } return plain, nil } func encryptBootstrapPeerSecret(rawSecret string) (string, error) { - key, err := getOrCreatePeerSecretEncryptionKey() + key, err := GetOrCreatePeerSecretEncryptionKey() if err != nil { return "", err } return util.EncryptAESGCM(rawSecret, key) } -func getOrCreateConfigKey(key string, size int) (string, error) { +// GetOrCreateConfigSecret читает секрет из таблицы `config`, создавая его при +// первом обращении. Единственная реализация этой операции в продукте. +func GetOrCreateConfigSecret(key string, size int) (string, error) { cfg, err := GetConfig("key = ?", key) if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" { return strings.TrimSpace(*cfg.Value), nil } - raw, genErr := util.RandomString(size) + value, genErr := util.RandomString(size) if genErr != nil { return "", genErr } - value := raw - remark := key - if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); saveErr != nil { - if updErr := UpdateConfig([]string{key}, map[string]interface{}{"value": value}); updErr != nil { - return "", updErr - } + if err := UpsertConfigValue(key, value); err != nil { + return "", err } return value, nil } @@ -300,6 +339,7 @@ func runMigrations() error { {version: "001_admin_peer_split", apply: migrateAdminPeerSplit}, {version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables}, {version: "005_metric_sample", apply: migrateMetricSampleTable}, + {version: "006_drop_dead_config_keys", apply: migrateDropDeadConfigKeys}, } for _, m := range migrations { @@ -322,12 +362,16 @@ func runMigrations() error { func seedBaseConfig() error { // Сетевых настроек панели здесь нет: ими владеет оркестратор. + // + // HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и + // HYSTERIA2_CONFIG_REMARK удалены: ни один из них не имел потребителя в + // рантайме, см. model/constant/config.go. Строки этих ключей на уже + // установленных машинах убирает миграция 006_drop_dead_config_keys. + // + // Оставшееся — четыре внутренних секрета и единственная настройка, которой + // действительно владеет панель. defaults := map[string]string{ constant.JwtSecret: "", - constant.Hysteria2Enable: "0", - constant.Hysteria2Config: "", - constant.Hysteria2TrafficTime: "10", - constant.Hysteria2ConfigRemark: "", constant.ResetTrafficCron: "0 0 * * *", constant.Hysteria2TrafficStatsSecret: "", constant.PeerSecretKey: "", @@ -363,6 +407,34 @@ func migrateBaseConfig() error { return seedBaseConfig() } +// migrateDropDeadConfigKeys убирает из таблицы `config` ключи, у которых не +// осталось ни одного потребителя. +// +// Миграция нужна не ради места в базе, а ради того же инварианта, что и +// allowlist: строка в таблице секретов, которую никто не читает и не пишет, +// рано или поздно кем-нибудь прочитается. Ключи перечислены здесь строковыми +// литералами намеренно — соответствующих констант в коде уже нет, и появиться +// они не должны. +// +// Миграция безопасна на чистой установке (удалять нечего) и идемпотентна: +// применяется один раз и отмечается в schema_migrations. Номера предыдущих +// миграций не тронуты — они уже записаны на установленных машинах. +func migrateDropDeadConfigKeys() error { + deadKeys := []string{ + "HYSTERIA2_ENABLE", + "HYSTERIA2_CONFIG", + "HYSTERIA2_TRAFFIC_TIME", + "HYSTERIA2_CONFIG_REMARK", + } + for _, key := range deadKeys { + if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", key); tx.Error != nil { + logrus.Errorf("sqlite migration exec err: %v", tx.Error) + return errors.New("sqlite migration exec err") + } + } + return nil +} + func migrationApplied(version string) (bool, error) { var count int64 if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil { @@ -428,34 +500,17 @@ func getOrCreateJwtSecret() (string, error) { if secErr != nil { return "", secErr } - if err := UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": secret}); err != nil { - key := constant.JwtSecret - remark := "JWT signing secret" - if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil { - return "", saveErr - } + if err := UpsertConfigValue(constant.JwtSecret, secret); err != nil { + return "", err } return secret, nil } -func getOrCreatePeerSecretDigestKey() (string, error) { - if existing, err := GetConfig("key = ?", constant.PeerSecretKey); err == nil { - if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { - return strings.TrimSpace(*existing.Value), nil - } - } - keyValue, keyErr := util.RandomString(48) - if keyErr != nil { - return "", keyErr - } - if err := UpdateConfig([]string{constant.PeerSecretKey}, map[string]interface{}{"value": keyValue}); err != nil { - key := constant.PeerSecretKey - remark := "Peer secret digest key" - if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &keyValue, Remark: &remark}); saveErr != nil { - return "", saveErr - } - } - return keyValue, nil +// GetOrCreatePeerSecretDigestKey — HMAC-ключ, которым считается secret_digest +// пира. Экспортирована по той же причине, что и ключ шифрования: копия этой же +// логики жила в service/peer_secret.go под именем GetPeerSecretKey. +func GetOrCreatePeerSecretDigestKey() (string, error) { + return GetOrCreateConfigSecret(constant.PeerSecretKey, 48) } func migrateTrafficTables() error { @@ -542,43 +597,33 @@ func tableExists(tableName string) bool { } return count > 0 } +// ensureTrafficStatsSecret синхронизирует machine token с окружением. +// +// Владелец значения — оркестратор: он же кладёт этот секрет в +// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к +// /internal/hysteria/auth. Поэтому env всегда побеждает содержимое базы, а +// собственная генерация — только для случая, когда переменной нет вовсе. +// +// Четыре почти одинаковые ветки INSERT/UPDATE свёрнуты в UpsertConfigValue: +// каждая из них по-своему решала «есть строка или нет», и две из четырёх +// решали неверно (см. комментарий к UpsertConfigValue). func ensureTrafficStatsSecret() error { envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET")) if envSecret != "" { - if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil { - if existing.Value != nil && strings.TrimSpace(*existing.Value) != envSecret { - return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": envSecret}) - } - return nil - } - key := constant.Hysteria2TrafficStatsSecret - remark := "Hysteria2 trafficStats API secret" - if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &envSecret, Remark: &remark}); saveErr != nil { - return saveErr - } - return nil + return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret) } if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil { if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" { return nil } - secret, secErr := util.RandomString(32) - if secErr != nil { - return secErr - } - return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": secret}) } + secret, secErr := util.RandomString(32) if secErr != nil { return secErr } - key := constant.Hysteria2TrafficStatsSecret - remark := "Hysteria2 trafficStats API secret" - if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil { - return saveErr - } - return nil + return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, secret) } func CloseSqliteDB() error { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1015474..df111fc 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,7 +16,7 @@ "dependencies": { "@element-plus/icons-vue": "^1.0.0", "@vueuse/core": "^9.1.1", - "axios": "^1.3.4", + "axios": "^1.20.0", "copy-to-clipboard": "^3.3.3", "echarts": "^5.5.1", "element-plus": "^2.3.1", @@ -42,7 +42,7 @@ "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-vue": "^9.9.0", - "postcss": "^8.4.21", + "postcss": "^8.5.26", "postcss-html": "^1.5.0", "postcss-scss": "^4.0.6", "prettier": "^2.8.7", @@ -63,6 +63,12 @@ "vite-plugin-svg-icons": "^2.0.1", "vue-tsc": "^0.35.0" }, + "pnpm": { + "overrides": { + "lodash": ">=4.18.1", + "lodash-es": ">=4.18.1" + } + }, "engines": { "node": ">=18.12.0" } diff --git a/apps/frontend/pnpm-lock.yaml b/apps/frontend/pnpm-lock.yaml index caa5833..2176f45 100644 --- a/apps/frontend/pnpm-lock.yaml +++ b/apps/frontend/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + lodash: '>=4.18.1' + lodash-es: '>=4.18.1' + importers: .: @@ -15,8 +19,8 @@ importers: specifier: ^9.1.1 version: 9.1.1(vue@3.2.45) axios: - specifier: ^1.3.4 - version: 1.3.4 + specifier: ^1.20.0 + version: 1.20.0 copy-to-clipboard: specifier: ^3.3.3 version: 3.3.3 @@ -74,7 +78,7 @@ importers: version: 4.0.0(vite@4.3.1(@types/node@20.7.0)(sass@1.58.3))(vue@3.2.45) autoprefixer: specifier: ^10.4.13 - version: 10.4.13(postcss@8.4.21) + version: 10.4.13(postcss@8.5.26) eslint: specifier: ^8.34.0 version: 8.34.0 @@ -88,14 +92,14 @@ importers: specifier: ^9.9.0 version: 9.9.0(eslint@8.34.0) postcss: - specifier: ^8.4.21 - version: 8.4.21 + specifier: ^8.5.26 + version: 8.5.26 postcss-html: specifier: ^1.5.0 version: 1.5.0 postcss-scss: specifier: ^4.0.6 - version: 4.0.6(postcss@8.4.21) + version: 4.0.6(postcss@8.5.26) prettier: specifier: ^2.8.7 version: 2.8.7 @@ -113,7 +117,7 @@ importers: version: 4.0.0(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-recommended-scss: specifier: ^9.0.1 - version: 9.0.1(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)) + version: 9.0.1(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-recommended-vue: specifier: ^1.4.0 version: 1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3)) @@ -122,7 +126,7 @@ importers: version: 32.0.0(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-standard-scss: specifier: ^8.0.0 - version: 8.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)) + version: 8.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)) typescript: specifier: ^4.9.3 version: 4.9.3 @@ -532,8 +536,8 @@ packages: rollup: optional: true - '@sxzz/popperjs-es@2.11.7': - resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==} + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} @@ -881,8 +885,8 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.3.4: - resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -937,6 +941,10 @@ packages: resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} engines: {node: '>=0.10.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1189,6 +1197,10 @@ packages: resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} engines: {node: '>=12'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -1231,6 +1243,22 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.17.19: resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} engines: {node: '>=12'} @@ -1414,8 +1442,8 @@ packages: flatted@3.2.9: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} - follow-redirects@1.15.3: - resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -1427,8 +1455,8 @@ packages: resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} engines: {node: '>=0.10.0'} - form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} fraction.js@4.3.6: @@ -1457,10 +1485,21 @@ packages: function-bind@1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -1512,6 +1551,10 @@ packages: globjoin@0.1.4: resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1542,6 +1585,14 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + has-value@0.3.1: resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==} engines: {node: '>=0.10.0'} @@ -1565,6 +1616,10 @@ packages: hash-sum@2.0.0: resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -1848,15 +1903,15 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} lodash-unified@1.0.3: resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} peerDependencies: '@types/lodash-es': '*' - lodash: '*' - lodash-es: '*' + lodash: '>=4.18.1' + lodash-es: '>=4.18.1' lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -1864,8 +1919,8 @@ packages: lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1901,6 +1956,10 @@ packages: resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} engines: {node: '>=0.10.0'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mathml-tag-names@2.1.3: resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} @@ -2008,8 +2067,8 @@ packages: ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2159,6 +2218,9 @@ packages: picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -2228,8 +2290,8 @@ packages: resolution: {integrity: sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==} engines: {node: '>=0.12'} - postcss@8.4.21: - resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} posthtml-parser@0.2.1: @@ -2265,8 +2327,9 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -2458,6 +2521,10 @@ packages: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map-resolve@0.5.3: resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==} deprecated: See https://github.com/lydell/source-map-resolve#deprecated @@ -3389,7 +3456,7 @@ snapshots: optionalDependencies: rollup: 3.29.3 - '@sxzz/popperjs-es@2.11.7': {} + '@sxzz/popperjs-es@2.11.8': {} '@trysound/sax@0.2.0': {} @@ -3682,7 +3749,7 @@ snapshots: '@vue/shared': 3.2.45 estree-walker: 2.0.2 magic-string: 0.25.9 - postcss: 8.4.21 + postcss: 8.5.26 source-map: 0.6.1 '@vue/compiler-sfc@3.3.4': @@ -3695,7 +3762,7 @@ snapshots: '@vue/shared': 3.3.4 estree-walker: 2.0.2 magic-string: 0.30.3 - postcss: 8.4.21 + postcss: 8.5.26 source-map-js: 1.0.2 '@vue/compiler-ssr@3.2.45': @@ -3843,23 +3910,25 @@ snapshots: atob@2.1.2: {} - autoprefixer@10.4.13(postcss@8.4.21): + autoprefixer@10.4.13(postcss@8.5.26): dependencies: browserslist: 4.21.11 caniuse-lite: 1.0.30001539 fraction.js: 4.3.6 normalize-range: 0.1.2 picocolors: 1.0.0 - postcss: 8.4.21 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - axios@1.3.4: + axios@1.20.0: dependencies: - follow-redirects: 1.15.3 - form-data: 4.0.0 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color balanced-match@1.0.2: {} @@ -3948,6 +4017,11 @@ snapshots: union-value: 1.0.1 unset-value: 1.0.0 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + callsites@3.1.0: {} camelcase-keys@6.2.2: @@ -4197,6 +4271,12 @@ snapshots: dotenv@16.3.1: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} echarts@5.6.0: @@ -4211,16 +4291,16 @@ snapshots: '@ctrl/tinycolor': 3.6.1 '@element-plus/icons-vue': 2.1.0(vue@3.2.45) '@floating-ui/dom': 1.5.3 - '@popperjs/core': '@sxzz/popperjs-es@2.11.7' + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' '@types/lodash': 4.14.199 '@types/lodash-es': 4.17.9 '@vueuse/core': 9.1.1(vue@3.2.45) async-validator: 4.2.5 dayjs: 1.11.10 escape-html: 1.0.3 - lodash: 4.17.21 - lodash-es: 4.17.21 - lodash-unified: 1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.17.21)(lodash@4.17.21) + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.18.1)(lodash@4.18.1) memoize-one: 6.0.0 normalize-wheel-es: 1.2.0 vue: 3.2.45 @@ -4251,6 +4331,21 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.17.19: optionalDependencies: '@esbuild/android-arm': 0.17.19 @@ -4505,14 +4600,16 @@ snapshots: flatted@3.2.9: {} - follow-redirects@1.15.3: {} + follow-redirects@1.16.0: {} for-in@1.0.2: {} - form-data@4.0.0: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 mime-types: 2.1.35 fraction.js@4.3.6: {} @@ -4538,8 +4635,28 @@ snapshots: function-bind@1.1.1: {} + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-stream@6.0.1: {} get-value@2.0.6: {} @@ -4608,6 +4725,8 @@ snapshots: globjoin@0.1.4: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} grapheme-splitter@1.0.4: {} @@ -4628,6 +4747,12 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + has-value@0.3.1: dependencies: get-value: 2.0.6 @@ -4653,6 +4778,10 @@ snapshots: hash-sum@2.0.0: {} + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} hookable@5.5.3: {} @@ -4885,19 +5014,19 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.21: {} + lodash-es@4.18.1: {} - lodash-unified@1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.17.21)(lodash@4.17.21): + lodash-unified@1.0.3(@types/lodash-es@4.17.9)(lodash-es@4.18.1)(lodash@4.18.1): dependencies: '@types/lodash-es': 4.17.9 - lodash: 4.17.21 - lodash-es: 4.17.21 + lodash: 4.18.1 + lodash-es: 4.18.1 lodash.merge@4.6.2: {} lodash.truncate@4.4.2: {} - lodash@4.17.21: {} + lodash@4.18.1: {} lru-cache@5.1.1: dependencies: @@ -4929,6 +5058,8 @@ snapshots: dependencies: object-visit: 1.0.1 + math-intrinsics@1.1.0: {} + mathml-tag-names@2.1.3: {} mdn-data@2.0.14: {} @@ -5047,7 +5178,7 @@ snapshots: ms@2.1.2: {} - nanoid@3.3.6: {} + nanoid@3.3.18: {} nanomatch@1.2.13: dependencies: @@ -5197,6 +5328,8 @@ snapshots: picocolors@1.0.0: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} pinia@2.0.33(typescript@4.9.3)(vue@3.2.45): @@ -5219,8 +5352,8 @@ snapshots: dependencies: htmlparser2: 8.0.2 js-tokens: 8.0.2 - postcss: 8.4.21 - postcss-safe-parser: 6.0.0(postcss@8.4.21) + postcss: 8.5.26 + postcss-safe-parser: 6.0.0(postcss@8.5.26) postcss-import-resolver@2.0.0: dependencies: @@ -5234,22 +5367,22 @@ snapshots: postcss-resolve-nested-selector@0.1.1: {} - postcss-safe-parser@6.0.0(postcss@8.4.21): + postcss-safe-parser@6.0.0(postcss@8.5.26): dependencies: - postcss: 8.4.21 + postcss: 8.5.26 - postcss-scss@4.0.6(postcss@8.4.21): + postcss-scss@4.0.6(postcss@8.5.26): dependencies: - postcss: 8.4.21 + postcss: 8.5.26 postcss-selector-parser@6.0.13: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-sorting@8.0.2(postcss@8.4.21): + postcss-sorting@8.0.2(postcss@8.5.26): dependencies: - postcss: 8.4.21 + postcss: 8.5.26 postcss-value-parser@4.2.0: {} @@ -5260,11 +5393,11 @@ snapshots: source-map: 0.5.7 supports-color: 3.2.3 - postcss@8.4.21: + postcss@8.5.26: dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 posthtml-parser@0.2.1: dependencies: @@ -5299,7 +5432,7 @@ snapshots: process-nextick-args@2.0.1: {} - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} prr@1.0.1: {} @@ -5487,6 +5620,8 @@ snapshots: source-map-js@1.0.2: {} + source-map-js@1.2.1: {} + source-map-resolve@0.5.3: dependencies: atob: 2.1.2 @@ -5578,23 +5713,23 @@ snapshots: stylelint: 15.5.0(typescript@4.9.3) stylelint-order: 6.0.3(stylelint@15.5.0(typescript@4.9.3)) - stylelint-config-recommended-scss@10.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)): + stylelint-config-recommended-scss@10.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)): dependencies: - postcss-scss: 4.0.6(postcss@8.4.21) + postcss-scss: 4.0.6(postcss@8.5.26) stylelint: 15.5.0(typescript@4.9.3) stylelint-config-recommended: 11.0.0(stylelint@15.5.0(typescript@4.9.3)) stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3)) optionalDependencies: - postcss: 8.4.21 + postcss: 8.5.26 - stylelint-config-recommended-scss@9.0.1(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)): + stylelint-config-recommended-scss@9.0.1(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)): dependencies: - postcss-scss: 4.0.6(postcss@8.4.21) + postcss-scss: 4.0.6(postcss@8.5.26) stylelint: 15.5.0(typescript@4.9.3) stylelint-config-recommended: 10.0.1(stylelint@15.5.0(typescript@4.9.3)) stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3)) optionalDependencies: - postcss: 8.4.21 + postcss: 8.5.26 stylelint-config-recommended-vue@1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3)): dependencies: @@ -5616,13 +5751,13 @@ snapshots: dependencies: stylelint: 15.5.0(typescript@4.9.3) - stylelint-config-standard-scss@8.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)): + stylelint-config-standard-scss@8.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)): dependencies: stylelint: 15.5.0(typescript@4.9.3) - stylelint-config-recommended-scss: 10.0.0(postcss@8.4.21)(stylelint@15.5.0(typescript@4.9.3)) + stylelint-config-recommended-scss: 10.0.0(postcss@8.5.26)(stylelint@15.5.0(typescript@4.9.3)) stylelint-config-standard: 32.0.0(stylelint@15.5.0(typescript@4.9.3)) optionalDependencies: - postcss: 8.4.21 + postcss: 8.5.26 stylelint-config-standard@32.0.0(stylelint@15.5.0(typescript@4.9.3)): dependencies: @@ -5631,8 +5766,8 @@ snapshots: stylelint-order@6.0.3(stylelint@15.5.0(typescript@4.9.3)): dependencies: - postcss: 8.4.21 - postcss-sorting: 8.0.2(postcss@8.4.21) + postcss: 8.5.26 + postcss-sorting: 8.0.2(postcss@8.5.26) stylelint: 15.5.0(typescript@4.9.3) stylelint-scss@4.7.0(stylelint@15.5.0(typescript@4.9.3)): @@ -5672,10 +5807,10 @@ snapshots: micromatch: 4.0.5 normalize-path: 3.0.0 picocolors: 1.0.0 - postcss: 8.4.21 + postcss: 8.5.26 postcss-media-query-parser: 0.2.3 postcss-resolve-nested-selector: 0.1.1 - postcss-safe-parser: 6.0.0(postcss@8.4.21) + postcss-safe-parser: 6.0.0(postcss@8.5.26) postcss-selector-parser: 6.0.13 postcss-value-parser: 4.2.0 resolve-from: 5.0.0 @@ -6010,7 +6145,7 @@ snapshots: vite@4.3.1(@types/node@20.7.0)(sass@1.58.3): dependencies: esbuild: 0.17.19 - postcss: 8.4.21 + postcss: 8.5.26 rollup: 3.29.3 optionalDependencies: '@types/node': 20.7.0 @@ -6043,7 +6178,7 @@ snapshots: eslint-visitor-keys: 3.4.3 espree: 9.6.1 esquery: 1.5.0 - lodash: 4.17.21 + lodash: 4.18.1 semver: 7.5.4 transitivePeerDependencies: - supports-color diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index d5845e3..338a8ed 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -129,7 +129,7 @@ export default { form: { namePlaceholder: "e.g. ivan-laptop", nameHint: - "Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret.", + "Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.", remarkPlaceholder: "e.g. Ivan's laptop, sales team", remarkHint: "Optional operator note. It is never shown to the client.", secretPlaceholder: "leave empty to generate automatically", @@ -185,13 +185,11 @@ export default { }, config: { orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure", - hysteria2TrafficTime: "Hysteria2 Traffic Time", resetTrafficCron: "Reset traffic schedule task", resetTrafficCronTip: - "Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3", + "Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3. An empty value disables the automatic reset", resetTrafficMonth: "Run once a month, midnight, first of month", resetTrafficWeek: "Run once a week, midnight between Sat/Sun", - invalidTrafficTime: "Field must be a number with up to one decimal place", }, monitor: { cpuPercent: "CPU Usage", @@ -229,7 +227,6 @@ export default { hysteria2Version: "Hysteria2 Version", hysteria2Running: "Hysteria2 Status", addOutbound: "Add Outbound", - extension: "Extension", listen: "Listen", tls: "TLS", obfs: "Obfuscation", @@ -245,8 +242,6 @@ export default { http: "Traffic Stats API (HTTP)", masquerade: "Masquerade", config: { - enable: "Enable/Disable", - remark: "Remark", listen: "When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.", tlsType: "TLS type", diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index 02fb78f..5b27225 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -125,7 +125,7 @@ export default { form: { namePlaceholder: "например, ivan-laptop", nameHint: - "Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.", + "Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.", remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж", remarkHint: "Необязательная пометка для оператора. Клиент её не видит.", secretPlaceholder: "оставьте пустым — сгенерируем автоматически", @@ -181,13 +181,11 @@ export default { }, config: { orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure", - hysteria2TrafficTime: "Период учёта трафика Hysteria2", resetTrafficCron: "Расписание сброса трафика", - resetTrafficCronTip: "Cron-выражение для планового сброса трафика", + resetTrafficCronTip: + "Cron-выражение для планового сброса трафика. Пустое значение отключает автоматический сброс", resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", - invalidTrafficTime: - "Поле должно быть числом максимум с одним знаком после запятой", }, monitor: { cpuPercent: "CPU", @@ -225,7 +223,6 @@ export default { hysteria2Version: "Версия Hysteria2", hysteria2Running: "Статус Hysteria2", addOutbound: "Добавить outbound", - extension: "Расширение", listen: "Адрес прослушивания", tls: "TLS", obfs: "Маскировка", @@ -241,8 +238,6 @@ export default { http: "Traffic Stats API (HTTP)", masquerade: "Masquerade", config: { - enable: "Включить/отключить", - remark: "Комментарий", listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.", tlsType: "Тип TLS", tls: { diff --git a/apps/frontend/src/types/auto-imports.d.ts b/apps/frontend/src/types/auto-imports.d.ts index 3a240a3..d663c77 100644 --- a/apps/frontend/src/types/auto-imports.d.ts +++ b/apps/frontend/src/types/auto-imports.d.ts @@ -1,874 +1,526 @@ // Generated by 'unplugin-auto-import' -export {}; +export {} declare global { - const EffectScope: typeof import("vue")["EffectScope"]; - const ElForm: typeof import("element-plus/es")["ElForm"]; - const ElMessage: typeof import("element-plus/es")["ElMessage"]; - const ElMessageBox: typeof import("element-plus/es")["ElMessageBox"]; - const asyncComputed: typeof import("@vueuse/core")["asyncComputed"]; - const autoResetRef: typeof import("@vueuse/core")["autoResetRef"]; - const computed: typeof import("vue")["computed"]; - const computedAsync: typeof import("@vueuse/core")["computedAsync"]; - const computedEager: typeof import("@vueuse/core")["computedEager"]; - const computedInject: typeof import("@vueuse/core")["computedInject"]; - const computedWithControl: typeof import("@vueuse/core")["computedWithControl"]; - const controlledComputed: typeof import("@vueuse/core")["controlledComputed"]; - const controlledRef: typeof import("@vueuse/core")["controlledRef"]; - const createApp: typeof import("vue")["createApp"]; - const createEventHook: typeof import("@vueuse/core")["createEventHook"]; - const createGlobalState: typeof import("@vueuse/core")["createGlobalState"]; - const createInjectionState: typeof import("@vueuse/core")["createInjectionState"]; - const createReactiveFn: typeof import("@vueuse/core")["createReactiveFn"]; - const createSharedComposable: typeof import("@vueuse/core")["createSharedComposable"]; - const createUnrefFn: typeof import("@vueuse/core")["createUnrefFn"]; - const customRef: typeof import("vue")["customRef"]; - const debouncedRef: typeof import("@vueuse/core")["debouncedRef"]; - const debouncedWatch: typeof import("@vueuse/core")["debouncedWatch"]; - const defineAsyncComponent: typeof import("vue")["defineAsyncComponent"]; - const defineComponent: typeof import("vue")["defineComponent"]; - const eagerComputed: typeof import("@vueuse/core")["eagerComputed"]; - const effectScope: typeof import("vue")["effectScope"]; - const extendRef: typeof import("@vueuse/core")["extendRef"]; - const getCurrentInstance: typeof import("vue")["getCurrentInstance"]; - const getCurrentScope: typeof import("vue")["getCurrentScope"]; - const h: typeof import("vue")["h"]; - const ignorableWatch: typeof import("@vueuse/core")["ignorableWatch"]; - const inject: typeof import("vue")["inject"]; - const isDefined: typeof import("@vueuse/core")["isDefined"]; - const isProxy: typeof import("vue")["isProxy"]; - const isReactive: typeof import("vue")["isReactive"]; - const isReadonly: typeof import("vue")["isReadonly"]; - const isRef: typeof import("vue")["isRef"]; - const makeDestructurable: typeof import("@vueuse/core")["makeDestructurable"]; - const markRaw: typeof import("vue")["markRaw"]; - const nextTick: typeof import("vue")["nextTick"]; - const onActivated: typeof import("vue")["onActivated"]; - const onBeforeMount: typeof import("vue")["onBeforeMount"]; - const onBeforeUnmount: typeof import("vue")["onBeforeUnmount"]; - const onBeforeUpdate: typeof import("vue")["onBeforeUpdate"]; - const onClickOutside: typeof import("@vueuse/core")["onClickOutside"]; - const onDeactivated: typeof import("vue")["onDeactivated"]; - const onErrorCaptured: typeof import("vue")["onErrorCaptured"]; - const onKeyStroke: typeof import("@vueuse/core")["onKeyStroke"]; - const onLongPress: typeof import("@vueuse/core")["onLongPress"]; - const onMounted: typeof import("vue")["onMounted"]; - const onRenderTracked: typeof import("vue")["onRenderTracked"]; - const onRenderTriggered: typeof import("vue")["onRenderTriggered"]; - const onScopeDispose: typeof import("vue")["onScopeDispose"]; - const onServerPrefetch: typeof import("vue")["onServerPrefetch"]; - const onStartTyping: typeof import("@vueuse/core")["onStartTyping"]; - const onUnmounted: typeof import("vue")["onUnmounted"]; - const onUpdated: typeof import("vue")["onUpdated"]; - const pausableWatch: typeof import("@vueuse/core")["pausableWatch"]; - const provide: typeof import("vue")["provide"]; - const reactify: typeof import("@vueuse/core")["reactify"]; - const reactifyObject: typeof import("@vueuse/core")["reactifyObject"]; - const reactive: typeof import("vue")["reactive"]; - const reactiveComputed: typeof import("@vueuse/core")["reactiveComputed"]; - const reactiveOmit: typeof import("@vueuse/core")["reactiveOmit"]; - const reactivePick: typeof import("@vueuse/core")["reactivePick"]; - const readonly: typeof import("vue")["readonly"]; - const ref: typeof import("vue")["ref"]; - const refAutoReset: typeof import("@vueuse/core")["refAutoReset"]; - const refDebounced: typeof import("@vueuse/core")["refDebounced"]; - const refDefault: typeof import("@vueuse/core")["refDefault"]; - const refThrottled: typeof import("@vueuse/core")["refThrottled"]; - const refWithControl: typeof import("@vueuse/core")["refWithControl"]; - const resolveComponent: typeof import("vue")["resolveComponent"]; - const resolveDirective: typeof import("vue")["resolveDirective"]; - const resolveRef: typeof import("@vueuse/core")["resolveRef"]; - const resolveUnref: typeof import("@vueuse/core")["resolveUnref"]; - const shallowReactive: typeof import("vue")["shallowReactive"]; - const shallowReadonly: typeof import("vue")["shallowReadonly"]; - const shallowRef: typeof import("vue")["shallowRef"]; - const syncRef: typeof import("@vueuse/core")["syncRef"]; - const syncRefs: typeof import("@vueuse/core")["syncRefs"]; - const templateRef: typeof import("@vueuse/core")["templateRef"]; - const throttledRef: typeof import("@vueuse/core")["throttledRef"]; - const throttledWatch: typeof import("@vueuse/core")["throttledWatch"]; - const toRaw: typeof import("vue")["toRaw"]; - const toReactive: typeof import("@vueuse/core")["toReactive"]; - const toRef: typeof import("vue")["toRef"]; - const toRefs: typeof import("vue")["toRefs"]; - const triggerRef: typeof import("vue")["triggerRef"]; - const tryOnBeforeMount: typeof import("@vueuse/core")["tryOnBeforeMount"]; - const tryOnBeforeUnmount: typeof import("@vueuse/core")["tryOnBeforeUnmount"]; - const tryOnMounted: typeof import("@vueuse/core")["tryOnMounted"]; - const tryOnScopeDispose: typeof import("@vueuse/core")["tryOnScopeDispose"]; - const tryOnUnmounted: typeof import("@vueuse/core")["tryOnUnmounted"]; - const unref: typeof import("vue")["unref"]; - const unrefElement: typeof import("@vueuse/core")["unrefElement"]; - const until: typeof import("@vueuse/core")["until"]; - const useActiveElement: typeof import("@vueuse/core")["useActiveElement"]; - const useArrayEvery: typeof import("@vueuse/core")["useArrayEvery"]; - const useArrayFilter: typeof import("@vueuse/core")["useArrayFilter"]; - const useArrayFind: typeof import("@vueuse/core")["useArrayFind"]; - const useArrayFindIndex: typeof import("@vueuse/core")["useArrayFindIndex"]; - const useArrayJoin: typeof import("@vueuse/core")["useArrayJoin"]; - const useArrayMap: typeof import("@vueuse/core")["useArrayMap"]; - const useArrayReduce: typeof import("@vueuse/core")["useArrayReduce"]; - const useArraySome: typeof import("@vueuse/core")["useArraySome"]; - const useAsyncQueue: typeof import("@vueuse/core")["useAsyncQueue"]; - const useAsyncState: typeof import("@vueuse/core")["useAsyncState"]; - const useAttrs: typeof import("vue")["useAttrs"]; - const useBase64: typeof import("@vueuse/core")["useBase64"]; - const useBattery: typeof import("@vueuse/core")["useBattery"]; - const useBluetooth: typeof import("@vueuse/core")["useBluetooth"]; - const useBreakpoints: typeof import("@vueuse/core")["useBreakpoints"]; - const useBroadcastChannel: typeof import("@vueuse/core")["useBroadcastChannel"]; - const useBrowserLocation: typeof import("@vueuse/core")["useBrowserLocation"]; - const useCached: typeof import("@vueuse/core")["useCached"]; - const useClipboard: typeof import("@vueuse/core")["useClipboard"]; - const useColorMode: typeof import("@vueuse/core")["useColorMode"]; - const useConfirmDialog: typeof import("@vueuse/core")["useConfirmDialog"]; - const useCounter: typeof import("@vueuse/core")["useCounter"]; - const useCssModule: typeof import("vue")["useCssModule"]; - const useCssVar: typeof import("@vueuse/core")["useCssVar"]; - const useCssVars: typeof import("vue")["useCssVars"]; - const useCurrentElement: typeof import("@vueuse/core")["useCurrentElement"]; - const useCycleList: typeof import("@vueuse/core")["useCycleList"]; - const useDark: typeof import("@vueuse/core")["useDark"]; - const useDateFormat: typeof import("@vueuse/core")["useDateFormat"]; - const useDebounce: typeof import("@vueuse/core")["useDebounce"]; - const useDebounceFn: typeof import("@vueuse/core")["useDebounceFn"]; - const useDebouncedRefHistory: typeof import("@vueuse/core")["useDebouncedRefHistory"]; - const useDeviceMotion: typeof import("@vueuse/core")["useDeviceMotion"]; - const useDeviceOrientation: typeof import("@vueuse/core")["useDeviceOrientation"]; - const useDevicePixelRatio: typeof import("@vueuse/core")["useDevicePixelRatio"]; - const useDevicesList: typeof import("@vueuse/core")["useDevicesList"]; - const useDisplayMedia: typeof import("@vueuse/core")["useDisplayMedia"]; - const useDocumentVisibility: typeof import("@vueuse/core")["useDocumentVisibility"]; - const useDraggable: typeof import("@vueuse/core")["useDraggable"]; - const useDropZone: typeof import("@vueuse/core")["useDropZone"]; - const useElementBounding: typeof import("@vueuse/core")["useElementBounding"]; - const useElementByPoint: typeof import("@vueuse/core")["useElementByPoint"]; - const useElementHover: typeof import("@vueuse/core")["useElementHover"]; - const useElementSize: typeof import("@vueuse/core")["useElementSize"]; - const useElementVisibility: typeof import("@vueuse/core")["useElementVisibility"]; - const useEventBus: typeof import("@vueuse/core")["useEventBus"]; - const useEventListener: typeof import("@vueuse/core")["useEventListener"]; - const useEventSource: typeof import("@vueuse/core")["useEventSource"]; - const useEyeDropper: typeof import("@vueuse/core")["useEyeDropper"]; - const useFavicon: typeof import("@vueuse/core")["useFavicon"]; - const useFetch: typeof import("@vueuse/core")["useFetch"]; - const useFileDialog: typeof import("@vueuse/core")["useFileDialog"]; - const useFileSystemAccess: typeof import("@vueuse/core")["useFileSystemAccess"]; - const useFocus: typeof import("@vueuse/core")["useFocus"]; - const useFocusWithin: typeof import("@vueuse/core")["useFocusWithin"]; - const useFps: typeof import("@vueuse/core")["useFps"]; - const useFullscreen: typeof import("@vueuse/core")["useFullscreen"]; - const useGamepad: typeof import("@vueuse/core")["useGamepad"]; - const useGeolocation: typeof import("@vueuse/core")["useGeolocation"]; - const useIdle: typeof import("@vueuse/core")["useIdle"]; - const useImage: typeof import("@vueuse/core")["useImage"]; - const useInfiniteScroll: typeof import("@vueuse/core")["useInfiniteScroll"]; - const useIntersectionObserver: typeof import("@vueuse/core")["useIntersectionObserver"]; - const useInterval: typeof import("@vueuse/core")["useInterval"]; - const useIntervalFn: typeof import("@vueuse/core")["useIntervalFn"]; - const useKeyModifier: typeof import("@vueuse/core")["useKeyModifier"]; - const useLastChanged: typeof import("@vueuse/core")["useLastChanged"]; - const useLocalStorage: typeof import("@vueuse/core")["useLocalStorage"]; - const useMagicKeys: typeof import("@vueuse/core")["useMagicKeys"]; - const useManualRefHistory: typeof import("@vueuse/core")["useManualRefHistory"]; - const useMediaControls: typeof import("@vueuse/core")["useMediaControls"]; - const useMediaQuery: typeof import("@vueuse/core")["useMediaQuery"]; - const useMemoize: typeof import("@vueuse/core")["useMemoize"]; - const useMemory: typeof import("@vueuse/core")["useMemory"]; - const useMounted: typeof import("@vueuse/core")["useMounted"]; - const useMouse: typeof import("@vueuse/core")["useMouse"]; - const useMouseInElement: typeof import("@vueuse/core")["useMouseInElement"]; - const useMousePressed: typeof import("@vueuse/core")["useMousePressed"]; - const useMutationObserver: typeof import("@vueuse/core")["useMutationObserver"]; - const useNavigatorLanguage: typeof import("@vueuse/core")["useNavigatorLanguage"]; - const useNetwork: typeof import("@vueuse/core")["useNetwork"]; - const useNow: typeof import("@vueuse/core")["useNow"]; - const useObjectUrl: typeof import("@vueuse/core")["useObjectUrl"]; - const useOffsetPagination: typeof import("@vueuse/core")["useOffsetPagination"]; - const useOnline: typeof import("@vueuse/core")["useOnline"]; - const usePageLeave: typeof import("@vueuse/core")["usePageLeave"]; - const useParallax: typeof import("@vueuse/core")["useParallax"]; - const usePermission: typeof import("@vueuse/core")["usePermission"]; - const usePointer: typeof import("@vueuse/core")["usePointer"]; - const usePointerSwipe: typeof import("@vueuse/core")["usePointerSwipe"]; - const usePreferredColorScheme: typeof import("@vueuse/core")["usePreferredColorScheme"]; - const usePreferredContrast: typeof import("@vueuse/core")["usePreferredContrast"]; - const usePreferredDark: typeof import("@vueuse/core")["usePreferredDark"]; - const usePreferredLanguages: typeof import("@vueuse/core")["usePreferredLanguages"]; - const usePreferredReducedMotion: typeof import("@vueuse/core")["usePreferredReducedMotion"]; - const useRafFn: typeof import("@vueuse/core")["useRafFn"]; - const useRefHistory: typeof import("@vueuse/core")["useRefHistory"]; - const useResizeObserver: typeof import("@vueuse/core")["useResizeObserver"]; - const useScreenOrientation: typeof import("@vueuse/core")["useScreenOrientation"]; - const useScreenSafeArea: typeof import("@vueuse/core")["useScreenSafeArea"]; - const useScriptTag: typeof import("@vueuse/core")["useScriptTag"]; - const useScroll: typeof import("@vueuse/core")["useScroll"]; - const useScrollLock: typeof import("@vueuse/core")["useScrollLock"]; - const useSessionStorage: typeof import("@vueuse/core")["useSessionStorage"]; - const useShare: typeof import("@vueuse/core")["useShare"]; - const useSlots: typeof import("vue")["useSlots"]; - const useSpeechRecognition: typeof import("@vueuse/core")["useSpeechRecognition"]; - const useSpeechSynthesis: typeof import("@vueuse/core")["useSpeechSynthesis"]; - const useStepper: typeof import("@vueuse/core")["useStepper"]; - const useStorage: typeof import("@vueuse/core")["useStorage"]; - const useStorageAsync: typeof import("@vueuse/core")["useStorageAsync"]; - const useStyleTag: typeof import("@vueuse/core")["useStyleTag"]; - const useSupported: typeof import("@vueuse/core")["useSupported"]; - const useSwipe: typeof import("@vueuse/core")["useSwipe"]; - const useTemplateRefsList: typeof import("@vueuse/core")["useTemplateRefsList"]; - const useTextDirection: typeof import("@vueuse/core")["useTextDirection"]; - const useTextSelection: typeof import("@vueuse/core")["useTextSelection"]; - const useTextareaAutosize: typeof import("@vueuse/core")["useTextareaAutosize"]; - const useThrottle: typeof import("@vueuse/core")["useThrottle"]; - const useThrottleFn: typeof import("@vueuse/core")["useThrottleFn"]; - const useThrottledRefHistory: typeof import("@vueuse/core")["useThrottledRefHistory"]; - const useTimeAgo: typeof import("@vueuse/core")["useTimeAgo"]; - const useTimeout: typeof import("@vueuse/core")["useTimeout"]; - const useTimeoutFn: typeof import("@vueuse/core")["useTimeoutFn"]; - const useTimeoutPoll: typeof import("@vueuse/core")["useTimeoutPoll"]; - const useTimestamp: typeof import("@vueuse/core")["useTimestamp"]; - const useTitle: typeof import("@vueuse/core")["useTitle"]; - const useToNumber: typeof import("@vueuse/core")["useToNumber"]; - const useToString: typeof import("@vueuse/core")["useToString"]; - const useToggle: typeof import("@vueuse/core")["useToggle"]; - const useTransition: typeof import("@vueuse/core")["useTransition"]; - const useUrlSearchParams: typeof import("@vueuse/core")["useUrlSearchParams"]; - const useUserMedia: typeof import("@vueuse/core")["useUserMedia"]; - const useVModel: typeof import("@vueuse/core")["useVModel"]; - const useVModels: typeof import("@vueuse/core")["useVModels"]; - const useVibrate: typeof import("@vueuse/core")["useVibrate"]; - const useVirtualList: typeof import("@vueuse/core")["useVirtualList"]; - const useWakeLock: typeof import("@vueuse/core")["useWakeLock"]; - const useWebNotification: typeof import("@vueuse/core")["useWebNotification"]; - const useWebSocket: typeof import("@vueuse/core")["useWebSocket"]; - const useWebWorker: typeof import("@vueuse/core")["useWebWorker"]; - const useWebWorkerFn: typeof import("@vueuse/core")["useWebWorkerFn"]; - const useWindowFocus: typeof import("@vueuse/core")["useWindowFocus"]; - const useWindowScroll: typeof import("@vueuse/core")["useWindowScroll"]; - const useWindowSize: typeof import("@vueuse/core")["useWindowSize"]; - const watch: typeof import("vue")["watch"]; - const watchArray: typeof import("@vueuse/core")["watchArray"]; - const watchAtMost: typeof import("@vueuse/core")["watchAtMost"]; - const watchDebounced: typeof import("@vueuse/core")["watchDebounced"]; - const watchEffect: typeof import("vue")["watchEffect"]; - const watchIgnorable: typeof import("@vueuse/core")["watchIgnorable"]; - const watchOnce: typeof import("@vueuse/core")["watchOnce"]; - const watchPausable: typeof import("@vueuse/core")["watchPausable"]; - const watchPostEffect: typeof import("vue")["watchPostEffect"]; - const watchSyncEffect: typeof import("vue")["watchSyncEffect"]; - const watchThrottled: typeof import("@vueuse/core")["watchThrottled"]; - const watchTriggerable: typeof import("@vueuse/core")["watchTriggerable"]; - const watchWithFilter: typeof import("@vueuse/core")["watchWithFilter"]; - const whenever: typeof import("@vueuse/core")["whenever"]; + const EffectScope: typeof import('vue')['EffectScope'] + const ElForm: typeof import('element-plus/es')['ElForm'] + const ElMessage: typeof import('element-plus/es')['ElMessage'] + const ElMessageBox: typeof import('element-plus/es')['ElMessageBox'] + const asyncComputed: typeof import('@vueuse/core')['asyncComputed'] + const autoResetRef: typeof import('@vueuse/core')['autoResetRef'] + const computed: typeof import('vue')['computed'] + const computedAsync: typeof import('@vueuse/core')['computedAsync'] + const computedEager: typeof import('@vueuse/core')['computedEager'] + const computedInject: typeof import('@vueuse/core')['computedInject'] + const computedWithControl: typeof import('@vueuse/core')['computedWithControl'] + const controlledComputed: typeof import('@vueuse/core')['controlledComputed'] + const controlledRef: typeof import('@vueuse/core')['controlledRef'] + const createApp: typeof import('vue')['createApp'] + const createEventHook: typeof import('@vueuse/core')['createEventHook'] + const createGlobalState: typeof import('@vueuse/core')['createGlobalState'] + const createInjectionState: typeof import('@vueuse/core')['createInjectionState'] + const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn'] + const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable'] + const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn'] + const customRef: typeof import('vue')['customRef'] + const debouncedRef: typeof import('@vueuse/core')['debouncedRef'] + const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const eagerComputed: typeof import('@vueuse/core')['eagerComputed'] + const effectScope: typeof import('vue')['effectScope'] + const extendRef: typeof import('@vueuse/core')['extendRef'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] + const inject: typeof import('vue')['inject'] + const isDefined: typeof import('@vueuse/core')['isDefined'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onClickOutside: typeof import('@vueuse/core')['onClickOutside'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke'] + const onLongPress: typeof import('@vueuse/core')['onLongPress'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onStartTyping: typeof import('@vueuse/core')['onStartTyping'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] + const provide: typeof import('vue')['provide'] + const reactify: typeof import('@vueuse/core')['reactify'] + const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] + const reactive: typeof import('vue')['reactive'] + const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed'] + const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit'] + const reactivePick: typeof import('@vueuse/core')['reactivePick'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const refAutoReset: typeof import('@vueuse/core')['refAutoReset'] + const refDebounced: typeof import('@vueuse/core')['refDebounced'] + const refDefault: typeof import('@vueuse/core')['refDefault'] + const refThrottled: typeof import('@vueuse/core')['refThrottled'] + const refWithControl: typeof import('@vueuse/core')['refWithControl'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const resolveDirective: typeof import('vue')['resolveDirective'] + const resolveRef: typeof import('@vueuse/core')['resolveRef'] + const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const syncRef: typeof import('@vueuse/core')['syncRef'] + const syncRefs: typeof import('@vueuse/core')['syncRefs'] + const templateRef: typeof import('@vueuse/core')['templateRef'] + const throttledRef: typeof import('@vueuse/core')['throttledRef'] + const throttledWatch: typeof import('@vueuse/core')['throttledWatch'] + const toRaw: typeof import('vue')['toRaw'] + const toReactive: typeof import('@vueuse/core')['toReactive'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const triggerRef: typeof import('vue')['triggerRef'] + const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount'] + const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount'] + const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted'] + const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose'] + const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted'] + const unref: typeof import('vue')['unref'] + const unrefElement: typeof import('@vueuse/core')['unrefElement'] + const until: typeof import('@vueuse/core')['until'] + const useActiveElement: typeof import('@vueuse/core')['useActiveElement'] + const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery'] + const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter'] + const useArrayFind: typeof import('@vueuse/core')['useArrayFind'] + const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex'] + const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin'] + const useArrayMap: typeof import('@vueuse/core')['useArrayMap'] + const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce'] + const useArraySome: typeof import('@vueuse/core')['useArraySome'] + const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue'] + const useAsyncState: typeof import('@vueuse/core')['useAsyncState'] + const useAttrs: typeof import('vue')['useAttrs'] + const useBase64: typeof import('@vueuse/core')['useBase64'] + const useBattery: typeof import('@vueuse/core')['useBattery'] + const useBluetooth: typeof import('@vueuse/core')['useBluetooth'] + const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints'] + const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel'] + const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation'] + const useCached: typeof import('@vueuse/core')['useCached'] + const useClipboard: typeof import('@vueuse/core')['useClipboard'] + const useColorMode: typeof import('@vueuse/core')['useColorMode'] + const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] + const useCounter: typeof import('@vueuse/core')['useCounter'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVar: typeof import('@vueuse/core')['useCssVar'] + const useCssVars: typeof import('vue')['useCssVars'] + const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement'] + const useCycleList: typeof import('@vueuse/core')['useCycleList'] + const useDark: typeof import('@vueuse/core')['useDark'] + const useDateFormat: typeof import('@vueuse/core')['useDateFormat'] + const useDebounce: typeof import('@vueuse/core')['useDebounce'] + const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn'] + const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory'] + const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion'] + const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation'] + const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio'] + const useDevicesList: typeof import('@vueuse/core')['useDevicesList'] + const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia'] + const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility'] + const useDraggable: typeof import('@vueuse/core')['useDraggable'] + const useDropZone: typeof import('@vueuse/core')['useDropZone'] + const useElementBounding: typeof import('@vueuse/core')['useElementBounding'] + const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint'] + const useElementHover: typeof import('@vueuse/core')['useElementHover'] + const useElementSize: typeof import('@vueuse/core')['useElementSize'] + const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility'] + const useEventBus: typeof import('@vueuse/core')['useEventBus'] + const useEventListener: typeof import('@vueuse/core')['useEventListener'] + const useEventSource: typeof import('@vueuse/core')['useEventSource'] + const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper'] + const useFavicon: typeof import('@vueuse/core')['useFavicon'] + const useFetch: typeof import('@vueuse/core')['useFetch'] + const useFileDialog: typeof import('@vueuse/core')['useFileDialog'] + const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess'] + const useFocus: typeof import('@vueuse/core')['useFocus'] + const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin'] + const useFps: typeof import('@vueuse/core')['useFps'] + const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] + const useGamepad: typeof import('@vueuse/core')['useGamepad'] + const useGeolocation: typeof import('@vueuse/core')['useGeolocation'] + const useIdle: typeof import('@vueuse/core')['useIdle'] + const useImage: typeof import('@vueuse/core')['useImage'] + const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll'] + const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver'] + const useInterval: typeof import('@vueuse/core')['useInterval'] + const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn'] + const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier'] + const useLastChanged: typeof import('@vueuse/core')['useLastChanged'] + const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage'] + const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys'] + const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory'] + const useMediaControls: typeof import('@vueuse/core')['useMediaControls'] + const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery'] + const useMemoize: typeof import('@vueuse/core')['useMemoize'] + const useMemory: typeof import('@vueuse/core')['useMemory'] + const useMounted: typeof import('@vueuse/core')['useMounted'] + const useMouse: typeof import('@vueuse/core')['useMouse'] + const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement'] + const useMousePressed: typeof import('@vueuse/core')['useMousePressed'] + const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver'] + const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage'] + const useNetwork: typeof import('@vueuse/core')['useNetwork'] + const useNow: typeof import('@vueuse/core')['useNow'] + const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl'] + const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination'] + const useOnline: typeof import('@vueuse/core')['useOnline'] + const usePageLeave: typeof import('@vueuse/core')['usePageLeave'] + const useParallax: typeof import('@vueuse/core')['useParallax'] + const usePermission: typeof import('@vueuse/core')['usePermission'] + const usePointer: typeof import('@vueuse/core')['usePointer'] + const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe'] + const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme'] + const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast'] + const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark'] + const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages'] + const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] + const useRafFn: typeof import('@vueuse/core')['useRafFn'] + const useRefHistory: typeof import('@vueuse/core')['useRefHistory'] + const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver'] + const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation'] + const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea'] + const useScriptTag: typeof import('@vueuse/core')['useScriptTag'] + const useScroll: typeof import('@vueuse/core')['useScroll'] + const useScrollLock: typeof import('@vueuse/core')['useScrollLock'] + const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage'] + const useShare: typeof import('@vueuse/core')['useShare'] + const useSlots: typeof import('vue')['useSlots'] + const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition'] + const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis'] + const useStepper: typeof import('@vueuse/core')['useStepper'] + const useStorage: typeof import('@vueuse/core')['useStorage'] + const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync'] + const useStyleTag: typeof import('@vueuse/core')['useStyleTag'] + const useSupported: typeof import('@vueuse/core')['useSupported'] + const useSwipe: typeof import('@vueuse/core')['useSwipe'] + const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList'] + const useTextDirection: typeof import('@vueuse/core')['useTextDirection'] + const useTextSelection: typeof import('@vueuse/core')['useTextSelection'] + const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize'] + const useThrottle: typeof import('@vueuse/core')['useThrottle'] + const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn'] + const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory'] + const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo'] + const useTimeout: typeof import('@vueuse/core')['useTimeout'] + const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn'] + const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll'] + const useTimestamp: typeof import('@vueuse/core')['useTimestamp'] + const useTitle: typeof import('@vueuse/core')['useTitle'] + const useToNumber: typeof import('@vueuse/core')['useToNumber'] + const useToString: typeof import('@vueuse/core')['useToString'] + const useToggle: typeof import('@vueuse/core')['useToggle'] + const useTransition: typeof import('@vueuse/core')['useTransition'] + const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams'] + const useUserMedia: typeof import('@vueuse/core')['useUserMedia'] + const useVModel: typeof import('@vueuse/core')['useVModel'] + const useVModels: typeof import('@vueuse/core')['useVModels'] + const useVibrate: typeof import('@vueuse/core')['useVibrate'] + const useVirtualList: typeof import('@vueuse/core')['useVirtualList'] + const useWakeLock: typeof import('@vueuse/core')['useWakeLock'] + const useWebNotification: typeof import('@vueuse/core')['useWebNotification'] + const useWebSocket: typeof import('@vueuse/core')['useWebSocket'] + const useWebWorker: typeof import('@vueuse/core')['useWebWorker'] + const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn'] + const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus'] + const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll'] + const useWindowSize: typeof import('@vueuse/core')['useWindowSize'] + const watch: typeof import('vue')['watch'] + const watchArray: typeof import('@vueuse/core')['watchArray'] + const watchAtMost: typeof import('@vueuse/core')['watchAtMost'] + const watchDebounced: typeof import('@vueuse/core')['watchDebounced'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable'] + const watchOnce: typeof import('@vueuse/core')['watchOnce'] + const watchPausable: typeof import('@vueuse/core')['watchPausable'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] + const watchThrottled: typeof import('@vueuse/core')['watchThrottled'] + const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable'] + const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter'] + const whenever: typeof import('@vueuse/core')['whenever'] } // for vue template auto import -import { UnwrapRef } from "vue"; -declare module "vue" { +import { UnwrapRef } from 'vue' +declare module 'vue' { interface ComponentCustomProperties { - readonly EffectScope: UnwrapRef; - readonly ElForm: UnwrapRef; - readonly ElMessage: UnwrapRef< - typeof import("element-plus/es")["ElMessage"] - >; - readonly ElMessageBox: UnwrapRef< - typeof import("element-plus/es")["ElMessageBox"] - >; - readonly asyncComputed: UnwrapRef< - typeof import("@vueuse/core")["asyncComputed"] - >; - readonly autoResetRef: UnwrapRef< - typeof import("@vueuse/core")["autoResetRef"] - >; - readonly computed: UnwrapRef; - readonly computedAsync: UnwrapRef< - typeof import("@vueuse/core")["computedAsync"] - >; - readonly computedEager: UnwrapRef< - typeof import("@vueuse/core")["computedEager"] - >; - readonly computedInject: UnwrapRef< - typeof import("@vueuse/core")["computedInject"] - >; - readonly computedWithControl: UnwrapRef< - typeof import("@vueuse/core")["computedWithControl"] - >; - readonly controlledComputed: UnwrapRef< - typeof import("@vueuse/core")["controlledComputed"] - >; - readonly controlledRef: UnwrapRef< - typeof import("@vueuse/core")["controlledRef"] - >; - readonly createApp: UnwrapRef; - readonly createEventHook: UnwrapRef< - typeof import("@vueuse/core")["createEventHook"] - >; - readonly createGlobalState: UnwrapRef< - typeof import("@vueuse/core")["createGlobalState"] - >; - readonly createInjectionState: UnwrapRef< - typeof import("@vueuse/core")["createInjectionState"] - >; - readonly createReactiveFn: UnwrapRef< - typeof import("@vueuse/core")["createReactiveFn"] - >; - readonly createSharedComposable: UnwrapRef< - typeof import("@vueuse/core")["createSharedComposable"] - >; - readonly createUnrefFn: UnwrapRef< - typeof import("@vueuse/core")["createUnrefFn"] - >; - readonly customRef: UnwrapRef; - readonly debouncedRef: UnwrapRef< - typeof import("@vueuse/core")["debouncedRef"] - >; - readonly debouncedWatch: UnwrapRef< - typeof import("@vueuse/core")["debouncedWatch"] - >; - readonly defineAsyncComponent: UnwrapRef< - typeof import("vue")["defineAsyncComponent"] - >; - readonly defineComponent: UnwrapRef< - typeof import("vue")["defineComponent"] - >; - readonly eagerComputed: UnwrapRef< - typeof import("@vueuse/core")["eagerComputed"] - >; - readonly effectScope: UnwrapRef; - readonly extendRef: UnwrapRef; - readonly getCurrentInstance: UnwrapRef< - typeof import("vue")["getCurrentInstance"] - >; - readonly getCurrentScope: UnwrapRef< - typeof import("vue")["getCurrentScope"] - >; - readonly h: UnwrapRef; - readonly ignorableWatch: UnwrapRef< - typeof import("@vueuse/core")["ignorableWatch"] - >; - readonly inject: UnwrapRef; - readonly isDefined: UnwrapRef; - readonly isProxy: UnwrapRef; - readonly isReactive: UnwrapRef; - readonly isReadonly: UnwrapRef; - readonly isRef: UnwrapRef; - readonly makeDestructurable: UnwrapRef< - typeof import("@vueuse/core")["makeDestructurable"] - >; - readonly markRaw: UnwrapRef; - readonly nextTick: UnwrapRef; - readonly onActivated: UnwrapRef; - readonly onBeforeMount: UnwrapRef; - readonly onBeforeUnmount: UnwrapRef< - typeof import("vue")["onBeforeUnmount"] - >; - readonly onBeforeUpdate: UnwrapRef; - readonly onClickOutside: UnwrapRef< - typeof import("@vueuse/core")["onClickOutside"] - >; - readonly onDeactivated: UnwrapRef; - readonly onErrorCaptured: UnwrapRef< - typeof import("vue")["onErrorCaptured"] - >; - readonly onKeyStroke: UnwrapRef< - typeof import("@vueuse/core")["onKeyStroke"] - >; - readonly onLongPress: UnwrapRef< - typeof import("@vueuse/core")["onLongPress"] - >; - readonly onMounted: UnwrapRef; - readonly onRenderTracked: UnwrapRef< - typeof import("vue")["onRenderTracked"] - >; - readonly onRenderTriggered: UnwrapRef< - typeof import("vue")["onRenderTriggered"] - >; - readonly onScopeDispose: UnwrapRef; - readonly onServerPrefetch: UnwrapRef< - typeof import("vue")["onServerPrefetch"] - >; - readonly onStartTyping: UnwrapRef< - typeof import("@vueuse/core")["onStartTyping"] - >; - readonly onUnmounted: UnwrapRef; - readonly onUpdated: UnwrapRef; - readonly pausableWatch: UnwrapRef< - typeof import("@vueuse/core")["pausableWatch"] - >; - readonly provide: UnwrapRef; - readonly reactify: UnwrapRef; - readonly reactifyObject: UnwrapRef< - typeof import("@vueuse/core")["reactifyObject"] - >; - readonly reactive: UnwrapRef; - readonly reactiveComputed: UnwrapRef< - typeof import("@vueuse/core")["reactiveComputed"] - >; - readonly reactiveOmit: UnwrapRef< - typeof import("@vueuse/core")["reactiveOmit"] - >; - readonly reactivePick: UnwrapRef< - typeof import("@vueuse/core")["reactivePick"] - >; - readonly readonly: UnwrapRef; - readonly ref: UnwrapRef; - readonly refAutoReset: UnwrapRef< - typeof import("@vueuse/core")["refAutoReset"] - >; - readonly refDebounced: UnwrapRef< - typeof import("@vueuse/core")["refDebounced"] - >; - readonly refDefault: UnwrapRef; - readonly refThrottled: UnwrapRef< - typeof import("@vueuse/core")["refThrottled"] - >; - readonly refWithControl: UnwrapRef< - typeof import("@vueuse/core")["refWithControl"] - >; - readonly resolveComponent: UnwrapRef< - typeof import("vue")["resolveComponent"] - >; - readonly resolveDirective: UnwrapRef< - typeof import("vue")["resolveDirective"] - >; - readonly resolveRef: UnwrapRef; - readonly resolveUnref: UnwrapRef< - typeof import("@vueuse/core")["resolveUnref"] - >; - readonly shallowReactive: UnwrapRef< - typeof import("vue")["shallowReactive"] - >; - readonly shallowReadonly: UnwrapRef< - typeof import("vue")["shallowReadonly"] - >; - readonly shallowRef: UnwrapRef; - readonly syncRef: UnwrapRef; - readonly syncRefs: UnwrapRef; - readonly templateRef: UnwrapRef< - typeof import("@vueuse/core")["templateRef"] - >; - readonly throttledRef: UnwrapRef< - typeof import("@vueuse/core")["throttledRef"] - >; - readonly throttledWatch: UnwrapRef< - typeof import("@vueuse/core")["throttledWatch"] - >; - readonly toRaw: UnwrapRef; - readonly toReactive: UnwrapRef; - readonly toRef: UnwrapRef; - readonly toRefs: UnwrapRef; - readonly triggerRef: UnwrapRef; - readonly tryOnBeforeMount: UnwrapRef< - typeof import("@vueuse/core")["tryOnBeforeMount"] - >; - readonly tryOnBeforeUnmount: UnwrapRef< - typeof import("@vueuse/core")["tryOnBeforeUnmount"] - >; - readonly tryOnMounted: UnwrapRef< - typeof import("@vueuse/core")["tryOnMounted"] - >; - readonly tryOnScopeDispose: UnwrapRef< - typeof import("@vueuse/core")["tryOnScopeDispose"] - >; - readonly tryOnUnmounted: UnwrapRef< - typeof import("@vueuse/core")["tryOnUnmounted"] - >; - readonly unref: UnwrapRef; - readonly unrefElement: UnwrapRef< - typeof import("@vueuse/core")["unrefElement"] - >; - readonly until: UnwrapRef; - readonly useActiveElement: UnwrapRef< - typeof import("@vueuse/core")["useActiveElement"] - >; - readonly useArrayEvery: UnwrapRef< - typeof import("@vueuse/core")["useArrayEvery"] - >; - readonly useArrayFilter: UnwrapRef< - typeof import("@vueuse/core")["useArrayFilter"] - >; - readonly useArrayFind: UnwrapRef< - typeof import("@vueuse/core")["useArrayFind"] - >; - readonly useArrayFindIndex: UnwrapRef< - typeof import("@vueuse/core")["useArrayFindIndex"] - >; - readonly useArrayJoin: UnwrapRef< - typeof import("@vueuse/core")["useArrayJoin"] - >; - readonly useArrayMap: UnwrapRef< - typeof import("@vueuse/core")["useArrayMap"] - >; - readonly useArrayReduce: UnwrapRef< - typeof import("@vueuse/core")["useArrayReduce"] - >; - readonly useArraySome: UnwrapRef< - typeof import("@vueuse/core")["useArraySome"] - >; - readonly useAsyncQueue: UnwrapRef< - typeof import("@vueuse/core")["useAsyncQueue"] - >; - readonly useAsyncState: UnwrapRef< - typeof import("@vueuse/core")["useAsyncState"] - >; - readonly useAttrs: UnwrapRef; - readonly useBase64: UnwrapRef; - readonly useBattery: UnwrapRef; - readonly useBluetooth: UnwrapRef< - typeof import("@vueuse/core")["useBluetooth"] - >; - readonly useBreakpoints: UnwrapRef< - typeof import("@vueuse/core")["useBreakpoints"] - >; - readonly useBroadcastChannel: UnwrapRef< - typeof import("@vueuse/core")["useBroadcastChannel"] - >; - readonly useBrowserLocation: UnwrapRef< - typeof import("@vueuse/core")["useBrowserLocation"] - >; - readonly useCached: UnwrapRef; - readonly useClipboard: UnwrapRef< - typeof import("@vueuse/core")["useClipboard"] - >; - readonly useColorMode: UnwrapRef< - typeof import("@vueuse/core")["useColorMode"] - >; - readonly useConfirmDialog: UnwrapRef< - typeof import("@vueuse/core")["useConfirmDialog"] - >; - readonly useCounter: UnwrapRef; - readonly useCssModule: UnwrapRef; - readonly useCssVar: UnwrapRef; - readonly useCssVars: UnwrapRef; - readonly useCurrentElement: UnwrapRef< - typeof import("@vueuse/core")["useCurrentElement"] - >; - readonly useCycleList: UnwrapRef< - typeof import("@vueuse/core")["useCycleList"] - >; - readonly useDark: UnwrapRef; - readonly useDateFormat: UnwrapRef< - typeof import("@vueuse/core")["useDateFormat"] - >; - readonly useDebounce: UnwrapRef< - typeof import("@vueuse/core")["useDebounce"] - >; - readonly useDebounceFn: UnwrapRef< - typeof import("@vueuse/core")["useDebounceFn"] - >; - readonly useDebouncedRefHistory: UnwrapRef< - typeof import("@vueuse/core")["useDebouncedRefHistory"] - >; - readonly useDeviceMotion: UnwrapRef< - typeof import("@vueuse/core")["useDeviceMotion"] - >; - readonly useDeviceOrientation: UnwrapRef< - typeof import("@vueuse/core")["useDeviceOrientation"] - >; - readonly useDevicePixelRatio: UnwrapRef< - typeof import("@vueuse/core")["useDevicePixelRatio"] - >; - readonly useDevicesList: UnwrapRef< - typeof import("@vueuse/core")["useDevicesList"] - >; - readonly useDisplayMedia: UnwrapRef< - typeof import("@vueuse/core")["useDisplayMedia"] - >; - readonly useDocumentVisibility: UnwrapRef< - typeof import("@vueuse/core")["useDocumentVisibility"] - >; - readonly useDraggable: UnwrapRef< - typeof import("@vueuse/core")["useDraggable"] - >; - readonly useDropZone: UnwrapRef< - typeof import("@vueuse/core")["useDropZone"] - >; - readonly useElementBounding: UnwrapRef< - typeof import("@vueuse/core")["useElementBounding"] - >; - readonly useElementByPoint: UnwrapRef< - typeof import("@vueuse/core")["useElementByPoint"] - >; - readonly useElementHover: UnwrapRef< - typeof import("@vueuse/core")["useElementHover"] - >; - readonly useElementSize: UnwrapRef< - typeof import("@vueuse/core")["useElementSize"] - >; - readonly useElementVisibility: UnwrapRef< - typeof import("@vueuse/core")["useElementVisibility"] - >; - readonly useEventBus: UnwrapRef< - typeof import("@vueuse/core")["useEventBus"] - >; - readonly useEventListener: UnwrapRef< - typeof import("@vueuse/core")["useEventListener"] - >; - readonly useEventSource: UnwrapRef< - typeof import("@vueuse/core")["useEventSource"] - >; - readonly useEyeDropper: UnwrapRef< - typeof import("@vueuse/core")["useEyeDropper"] - >; - readonly useFavicon: UnwrapRef; - readonly useFetch: UnwrapRef; - readonly useFileDialog: UnwrapRef< - typeof import("@vueuse/core")["useFileDialog"] - >; - readonly useFileSystemAccess: UnwrapRef< - typeof import("@vueuse/core")["useFileSystemAccess"] - >; - readonly useFocus: UnwrapRef; - readonly useFocusWithin: UnwrapRef< - typeof import("@vueuse/core")["useFocusWithin"] - >; - readonly useFps: UnwrapRef; - readonly useFullscreen: UnwrapRef< - typeof import("@vueuse/core")["useFullscreen"] - >; - readonly useGamepad: UnwrapRef; - readonly useGeolocation: UnwrapRef< - typeof import("@vueuse/core")["useGeolocation"] - >; - readonly useIdle: UnwrapRef; - readonly useImage: UnwrapRef; - readonly useInfiniteScroll: UnwrapRef< - typeof import("@vueuse/core")["useInfiniteScroll"] - >; - readonly useIntersectionObserver: UnwrapRef< - typeof import("@vueuse/core")["useIntersectionObserver"] - >; - readonly useInterval: UnwrapRef< - typeof import("@vueuse/core")["useInterval"] - >; - readonly useIntervalFn: UnwrapRef< - typeof import("@vueuse/core")["useIntervalFn"] - >; - readonly useKeyModifier: UnwrapRef< - typeof import("@vueuse/core")["useKeyModifier"] - >; - readonly useLastChanged: UnwrapRef< - typeof import("@vueuse/core")["useLastChanged"] - >; - readonly useLocalStorage: UnwrapRef< - typeof import("@vueuse/core")["useLocalStorage"] - >; - readonly useMagicKeys: UnwrapRef< - typeof import("@vueuse/core")["useMagicKeys"] - >; - readonly useManualRefHistory: UnwrapRef< - typeof import("@vueuse/core")["useManualRefHistory"] - >; - readonly useMediaControls: UnwrapRef< - typeof import("@vueuse/core")["useMediaControls"] - >; - readonly useMediaQuery: UnwrapRef< - typeof import("@vueuse/core")["useMediaQuery"] - >; - readonly useMemoize: UnwrapRef; - readonly useMemory: UnwrapRef; - readonly useMounted: UnwrapRef; - readonly useMouse: UnwrapRef; - readonly useMouseInElement: UnwrapRef< - typeof import("@vueuse/core")["useMouseInElement"] - >; - readonly useMousePressed: UnwrapRef< - typeof import("@vueuse/core")["useMousePressed"] - >; - readonly useMutationObserver: UnwrapRef< - typeof import("@vueuse/core")["useMutationObserver"] - >; - readonly useNavigatorLanguage: UnwrapRef< - typeof import("@vueuse/core")["useNavigatorLanguage"] - >; - readonly useNetwork: UnwrapRef; - readonly useNow: UnwrapRef; - readonly useObjectUrl: UnwrapRef< - typeof import("@vueuse/core")["useObjectUrl"] - >; - readonly useOffsetPagination: UnwrapRef< - typeof import("@vueuse/core")["useOffsetPagination"] - >; - readonly useOnline: UnwrapRef; - readonly usePageLeave: UnwrapRef< - typeof import("@vueuse/core")["usePageLeave"] - >; - readonly useParallax: UnwrapRef< - typeof import("@vueuse/core")["useParallax"] - >; - readonly usePermission: UnwrapRef< - typeof import("@vueuse/core")["usePermission"] - >; - readonly usePointer: UnwrapRef; - readonly usePointerSwipe: UnwrapRef< - typeof import("@vueuse/core")["usePointerSwipe"] - >; - readonly usePreferredColorScheme: UnwrapRef< - typeof import("@vueuse/core")["usePreferredColorScheme"] - >; - readonly usePreferredContrast: UnwrapRef< - typeof import("@vueuse/core")["usePreferredContrast"] - >; - readonly usePreferredDark: UnwrapRef< - typeof import("@vueuse/core")["usePreferredDark"] - >; - readonly usePreferredLanguages: UnwrapRef< - typeof import("@vueuse/core")["usePreferredLanguages"] - >; - readonly usePreferredReducedMotion: UnwrapRef< - typeof import("@vueuse/core")["usePreferredReducedMotion"] - >; - readonly useRafFn: UnwrapRef; - readonly useRefHistory: UnwrapRef< - typeof import("@vueuse/core")["useRefHistory"] - >; - readonly useResizeObserver: UnwrapRef< - typeof import("@vueuse/core")["useResizeObserver"] - >; - readonly useScreenOrientation: UnwrapRef< - typeof import("@vueuse/core")["useScreenOrientation"] - >; - readonly useScreenSafeArea: UnwrapRef< - typeof import("@vueuse/core")["useScreenSafeArea"] - >; - readonly useScriptTag: UnwrapRef< - typeof import("@vueuse/core")["useScriptTag"] - >; - readonly useScroll: UnwrapRef; - readonly useScrollLock: UnwrapRef< - typeof import("@vueuse/core")["useScrollLock"] - >; - readonly useSessionStorage: UnwrapRef< - typeof import("@vueuse/core")["useSessionStorage"] - >; - readonly useShare: UnwrapRef; - readonly useSlots: UnwrapRef; - readonly useSpeechRecognition: UnwrapRef< - typeof import("@vueuse/core")["useSpeechRecognition"] - >; - readonly useSpeechSynthesis: UnwrapRef< - typeof import("@vueuse/core")["useSpeechSynthesis"] - >; - readonly useStepper: UnwrapRef; - readonly useStorage: UnwrapRef; - readonly useStorageAsync: UnwrapRef< - typeof import("@vueuse/core")["useStorageAsync"] - >; - readonly useStyleTag: UnwrapRef< - typeof import("@vueuse/core")["useStyleTag"] - >; - readonly useSupported: UnwrapRef< - typeof import("@vueuse/core")["useSupported"] - >; - readonly useSwipe: UnwrapRef; - readonly useTemplateRefsList: UnwrapRef< - typeof import("@vueuse/core")["useTemplateRefsList"] - >; - readonly useTextDirection: UnwrapRef< - typeof import("@vueuse/core")["useTextDirection"] - >; - readonly useTextSelection: UnwrapRef< - typeof import("@vueuse/core")["useTextSelection"] - >; - readonly useTextareaAutosize: UnwrapRef< - typeof import("@vueuse/core")["useTextareaAutosize"] - >; - readonly useThrottle: UnwrapRef< - typeof import("@vueuse/core")["useThrottle"] - >; - readonly useThrottleFn: UnwrapRef< - typeof import("@vueuse/core")["useThrottleFn"] - >; - readonly useThrottledRefHistory: UnwrapRef< - typeof import("@vueuse/core")["useThrottledRefHistory"] - >; - readonly useTimeAgo: UnwrapRef; - readonly useTimeout: UnwrapRef; - readonly useTimeoutFn: UnwrapRef< - typeof import("@vueuse/core")["useTimeoutFn"] - >; - readonly useTimeoutPoll: UnwrapRef< - typeof import("@vueuse/core")["useTimeoutPoll"] - >; - readonly useTimestamp: UnwrapRef< - typeof import("@vueuse/core")["useTimestamp"] - >; - readonly useTitle: UnwrapRef; - readonly useToNumber: UnwrapRef< - typeof import("@vueuse/core")["useToNumber"] - >; - readonly useToString: UnwrapRef< - typeof import("@vueuse/core")["useToString"] - >; - readonly useToggle: UnwrapRef; - readonly useTransition: UnwrapRef< - typeof import("@vueuse/core")["useTransition"] - >; - readonly useUrlSearchParams: UnwrapRef< - typeof import("@vueuse/core")["useUrlSearchParams"] - >; - readonly useUserMedia: UnwrapRef< - typeof import("@vueuse/core")["useUserMedia"] - >; - readonly useVModel: UnwrapRef; - readonly useVModels: UnwrapRef; - readonly useVibrate: UnwrapRef; - readonly useVirtualList: UnwrapRef< - typeof import("@vueuse/core")["useVirtualList"] - >; - readonly useWakeLock: UnwrapRef< - typeof import("@vueuse/core")["useWakeLock"] - >; - readonly useWebNotification: UnwrapRef< - typeof import("@vueuse/core")["useWebNotification"] - >; - readonly useWebSocket: UnwrapRef< - typeof import("@vueuse/core")["useWebSocket"] - >; - readonly useWebWorker: UnwrapRef< - typeof import("@vueuse/core")["useWebWorker"] - >; - readonly useWebWorkerFn: UnwrapRef< - typeof import("@vueuse/core")["useWebWorkerFn"] - >; - readonly useWindowFocus: UnwrapRef< - typeof import("@vueuse/core")["useWindowFocus"] - >; - readonly useWindowScroll: UnwrapRef< - typeof import("@vueuse/core")["useWindowScroll"] - >; - readonly useWindowSize: UnwrapRef< - typeof import("@vueuse/core")["useWindowSize"] - >; - readonly watch: UnwrapRef; - readonly watchArray: UnwrapRef; - readonly watchAtMost: UnwrapRef< - typeof import("@vueuse/core")["watchAtMost"] - >; - readonly watchDebounced: UnwrapRef< - typeof import("@vueuse/core")["watchDebounced"] - >; - readonly watchEffect: UnwrapRef; - readonly watchIgnorable: UnwrapRef< - typeof import("@vueuse/core")["watchIgnorable"] - >; - readonly watchOnce: UnwrapRef; - readonly watchPausable: UnwrapRef< - typeof import("@vueuse/core")["watchPausable"] - >; - readonly watchPostEffect: UnwrapRef< - typeof import("vue")["watchPostEffect"] - >; - readonly watchSyncEffect: UnwrapRef< - typeof import("vue")["watchSyncEffect"] - >; - readonly watchThrottled: UnwrapRef< - typeof import("@vueuse/core")["watchThrottled"] - >; - readonly watchTriggerable: UnwrapRef< - typeof import("@vueuse/core")["watchTriggerable"] - >; - readonly watchWithFilter: UnwrapRef< - typeof import("@vueuse/core")["watchWithFilter"] - >; - readonly whenever: UnwrapRef; + readonly EffectScope: UnwrapRef + readonly ElForm: UnwrapRef + readonly ElMessage: UnwrapRef + readonly ElMessageBox: UnwrapRef + readonly asyncComputed: UnwrapRef + readonly autoResetRef: UnwrapRef + readonly computed: UnwrapRef + readonly computedAsync: UnwrapRef + readonly computedEager: UnwrapRef + readonly computedInject: UnwrapRef + readonly computedWithControl: UnwrapRef + readonly controlledComputed: UnwrapRef + readonly controlledRef: UnwrapRef + readonly createApp: UnwrapRef + readonly createEventHook: UnwrapRef + readonly createGlobalState: UnwrapRef + readonly createInjectionState: UnwrapRef + readonly createReactiveFn: UnwrapRef + readonly createSharedComposable: UnwrapRef + readonly createUnrefFn: UnwrapRef + readonly customRef: UnwrapRef + readonly debouncedRef: UnwrapRef + readonly debouncedWatch: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly eagerComputed: UnwrapRef + readonly effectScope: UnwrapRef + readonly extendRef: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly h: UnwrapRef + readonly ignorableWatch: UnwrapRef + readonly inject: UnwrapRef + readonly isDefined: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly makeDestructurable: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onClickOutside: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onKeyStroke: UnwrapRef + readonly onLongPress: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onStartTyping: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly pausableWatch: UnwrapRef + readonly provide: UnwrapRef + readonly reactify: UnwrapRef + readonly reactifyObject: UnwrapRef + readonly reactive: UnwrapRef + readonly reactiveComputed: UnwrapRef + readonly reactiveOmit: UnwrapRef + readonly reactivePick: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly refAutoReset: UnwrapRef + readonly refDebounced: UnwrapRef + readonly refDefault: UnwrapRef + readonly refThrottled: UnwrapRef + readonly refWithControl: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly resolveDirective: UnwrapRef + readonly resolveRef: UnwrapRef + readonly resolveUnref: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly syncRef: UnwrapRef + readonly syncRefs: UnwrapRef + readonly templateRef: UnwrapRef + readonly throttledRef: UnwrapRef + readonly throttledWatch: UnwrapRef + readonly toRaw: UnwrapRef + readonly toReactive: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly triggerRef: UnwrapRef + readonly tryOnBeforeMount: UnwrapRef + readonly tryOnBeforeUnmount: UnwrapRef + readonly tryOnMounted: UnwrapRef + readonly tryOnScopeDispose: UnwrapRef + readonly tryOnUnmounted: UnwrapRef + readonly unref: UnwrapRef + readonly unrefElement: UnwrapRef + readonly until: UnwrapRef + readonly useActiveElement: UnwrapRef + readonly useArrayEvery: UnwrapRef + readonly useArrayFilter: UnwrapRef + readonly useArrayFind: UnwrapRef + readonly useArrayFindIndex: UnwrapRef + readonly useArrayJoin: UnwrapRef + readonly useArrayMap: UnwrapRef + readonly useArrayReduce: UnwrapRef + readonly useArraySome: UnwrapRef + readonly useAsyncQueue: UnwrapRef + readonly useAsyncState: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useBase64: UnwrapRef + readonly useBattery: UnwrapRef + readonly useBluetooth: UnwrapRef + readonly useBreakpoints: UnwrapRef + readonly useBroadcastChannel: UnwrapRef + readonly useBrowserLocation: UnwrapRef + readonly useCached: UnwrapRef + readonly useClipboard: UnwrapRef + readonly useColorMode: UnwrapRef + readonly useConfirmDialog: UnwrapRef + readonly useCounter: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVar: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useCurrentElement: UnwrapRef + readonly useCycleList: UnwrapRef + readonly useDark: UnwrapRef + readonly useDateFormat: UnwrapRef + readonly useDebounce: UnwrapRef + readonly useDebounceFn: UnwrapRef + readonly useDebouncedRefHistory: UnwrapRef + readonly useDeviceMotion: UnwrapRef + readonly useDeviceOrientation: UnwrapRef + readonly useDevicePixelRatio: UnwrapRef + readonly useDevicesList: UnwrapRef + readonly useDisplayMedia: UnwrapRef + readonly useDocumentVisibility: UnwrapRef + readonly useDraggable: UnwrapRef + readonly useDropZone: UnwrapRef + readonly useElementBounding: UnwrapRef + readonly useElementByPoint: UnwrapRef + readonly useElementHover: UnwrapRef + readonly useElementSize: UnwrapRef + readonly useElementVisibility: UnwrapRef + readonly useEventBus: UnwrapRef + readonly useEventListener: UnwrapRef + readonly useEventSource: UnwrapRef + readonly useEyeDropper: UnwrapRef + readonly useFavicon: UnwrapRef + readonly useFetch: UnwrapRef + readonly useFileDialog: UnwrapRef + readonly useFileSystemAccess: UnwrapRef + readonly useFocus: UnwrapRef + readonly useFocusWithin: UnwrapRef + readonly useFps: UnwrapRef + readonly useFullscreen: UnwrapRef + readonly useGamepad: UnwrapRef + readonly useGeolocation: UnwrapRef + readonly useIdle: UnwrapRef + readonly useImage: UnwrapRef + readonly useInfiniteScroll: UnwrapRef + readonly useIntersectionObserver: UnwrapRef + readonly useInterval: UnwrapRef + readonly useIntervalFn: UnwrapRef + readonly useKeyModifier: UnwrapRef + readonly useLastChanged: UnwrapRef + readonly useLocalStorage: UnwrapRef + readonly useMagicKeys: UnwrapRef + readonly useManualRefHistory: UnwrapRef + readonly useMediaControls: UnwrapRef + readonly useMediaQuery: UnwrapRef + readonly useMemoize: UnwrapRef + readonly useMemory: UnwrapRef + readonly useMounted: UnwrapRef + readonly useMouse: UnwrapRef + readonly useMouseInElement: UnwrapRef + readonly useMousePressed: UnwrapRef + readonly useMutationObserver: UnwrapRef + readonly useNavigatorLanguage: UnwrapRef + readonly useNetwork: UnwrapRef + readonly useNow: UnwrapRef + readonly useObjectUrl: UnwrapRef + readonly useOffsetPagination: UnwrapRef + readonly useOnline: UnwrapRef + readonly usePageLeave: UnwrapRef + readonly useParallax: UnwrapRef + readonly usePermission: UnwrapRef + readonly usePointer: UnwrapRef + readonly usePointerSwipe: UnwrapRef + readonly usePreferredColorScheme: UnwrapRef + readonly usePreferredContrast: UnwrapRef + readonly usePreferredDark: UnwrapRef + readonly usePreferredLanguages: UnwrapRef + readonly usePreferredReducedMotion: UnwrapRef + readonly useRafFn: UnwrapRef + readonly useRefHistory: UnwrapRef + readonly useResizeObserver: UnwrapRef + readonly useScreenOrientation: UnwrapRef + readonly useScreenSafeArea: UnwrapRef + readonly useScriptTag: UnwrapRef + readonly useScroll: UnwrapRef + readonly useScrollLock: UnwrapRef + readonly useSessionStorage: UnwrapRef + readonly useShare: UnwrapRef + readonly useSlots: UnwrapRef + readonly useSpeechRecognition: UnwrapRef + readonly useSpeechSynthesis: UnwrapRef + readonly useStepper: UnwrapRef + readonly useStorage: UnwrapRef + readonly useStorageAsync: UnwrapRef + readonly useStyleTag: UnwrapRef + readonly useSupported: UnwrapRef + readonly useSwipe: UnwrapRef + readonly useTemplateRefsList: UnwrapRef + readonly useTextDirection: UnwrapRef + readonly useTextSelection: UnwrapRef + readonly useTextareaAutosize: UnwrapRef + readonly useThrottle: UnwrapRef + readonly useThrottleFn: UnwrapRef + readonly useThrottledRefHistory: UnwrapRef + readonly useTimeAgo: UnwrapRef + readonly useTimeout: UnwrapRef + readonly useTimeoutFn: UnwrapRef + readonly useTimeoutPoll: UnwrapRef + readonly useTimestamp: UnwrapRef + readonly useTitle: UnwrapRef + readonly useToNumber: UnwrapRef + readonly useToString: UnwrapRef + readonly useToggle: UnwrapRef + readonly useTransition: UnwrapRef + readonly useUrlSearchParams: UnwrapRef + readonly useUserMedia: UnwrapRef + readonly useVModel: UnwrapRef + readonly useVModels: UnwrapRef + readonly useVibrate: UnwrapRef + readonly useVirtualList: UnwrapRef + readonly useWakeLock: UnwrapRef + readonly useWebNotification: UnwrapRef + readonly useWebSocket: UnwrapRef + readonly useWebWorker: UnwrapRef + readonly useWebWorkerFn: UnwrapRef + readonly useWindowFocus: UnwrapRef + readonly useWindowScroll: UnwrapRef + readonly useWindowSize: UnwrapRef + readonly watch: UnwrapRef + readonly watchArray: UnwrapRef + readonly watchAtMost: UnwrapRef + readonly watchDebounced: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchIgnorable: UnwrapRef + readonly watchOnce: UnwrapRef + readonly watchPausable: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + readonly watchThrottled: UnwrapRef + readonly watchTriggerable: UnwrapRef + readonly watchWithFilter: UnwrapRef + readonly whenever: UnwrapRef } } diff --git a/apps/frontend/src/types/components.d.ts b/apps/frontend/src/types/components.d.ts index 5a5abba..2ad23b0 100644 --- a/apps/frontend/src/types/components.d.ts +++ b/apps/frontend/src/types/components.d.ts @@ -1,65 +1,63 @@ // generated by unplugin-vue-components // We suggest you to commit this file into source control // Read more: https://github.com/vuejs/core/pull/3399 -import "@vue/runtime-core"; +import '@vue/runtime-core' -export {}; +export {} -declare module "@vue/runtime-core" { +declare module '@vue/runtime-core' { export interface GlobalComponents { - ElAlert: typeof import("element-plus/es")["ElAlert"]; - ElAvatar: typeof import("element-plus/es")["ElAvatar"]; - ElButton: typeof import("element-plus/es")["ElButton"]; - ElCard: typeof import("element-plus/es")["ElCard"]; - ElCol: typeof import("element-plus/es")["ElCol"]; - ElDatePicker: typeof import("element-plus/es")["ElDatePicker"]; - ElDialog: typeof import("element-plus/es")["ElDialog"]; - ElDrawer: typeof import("element-plus/es")["ElDrawer"]; - ElDropdown: typeof import("element-plus/es")["ElDropdown"]; - ElDropdownItem: typeof import("element-plus/es")["ElDropdownItem"]; - ElDropdownMenu: typeof import("element-plus/es")["ElDropdownMenu"]; - ElEmpty: typeof import("element-plus/es")["ElEmpty"]; - ElForm: typeof import("element-plus/es")["ElForm"]; - ElFormItem: typeof import("element-plus/es")["ElFormItem"]; - ElInput: typeof import("element-plus/es")["ElInput"]; - ElInputNumber: typeof import("element-plus/es")["ElInputNumber"]; - ElMenu: typeof import("element-plus/es")["ElMenu"]; - ElMenuItem: typeof import("element-plus/es")["ElMenuItem"]; - ElOption: typeof import("element-plus/es")["ElOption"]; - ElPagination: typeof import("element-plus/es")["ElPagination"]; - ElProgress: typeof import("element-plus/es")["ElProgress"]; - ElRadioButton: typeof import("element-plus/es")["ElRadioButton"]; - ElRadioGroup: typeof import("element-plus/es")["ElRadioGroup"]; - ElRow: typeof import("element-plus/es")["ElRow"]; - ElScrollbar: typeof import("element-plus/es")["ElScrollbar"]; - ElSelect: typeof import("element-plus/es")["ElSelect"]; - ElSubMenu: typeof import("element-plus/es")["ElSubMenu"]; - ElSwitch: typeof import("element-plus/es")["ElSwitch"]; - ElTable: typeof import("element-plus/es")["ElTable"]; - ElTableColumn: typeof import("element-plus/es")["ElTableColumn"]; - ElTabPane: typeof import("element-plus/es")["ElTabPane"]; - ElTabs: typeof import("element-plus/es")["ElTabs"]; - ElTag: typeof import("element-plus/es")["ElTag"]; - ElTooltip: typeof import("element-plus/es")["ElTooltip"]; - ElUpload: typeof import("element-plus/es")["ElUpload"]; - Hamburger: typeof import("./../components/Hamburger/index.vue")["default"]; - IEpCaretBottom: typeof import("~icons/ep/caret-bottom")["default"]; - IEpDownload: typeof import("~icons/ep/download")["default"]; - IEpMoreFilled: typeof import("~icons/ep/more-filled")["default"]; - IEpRefresh: typeof import("~icons/ep/refresh")["default"]; - IEpRefreshRight: typeof import("~icons/ep/refresh-right")["default"]; - IEpUpload: typeof import("~icons/ep/upload")["default"]; - IEpUserFilled: typeof import("~icons/ep/user-filled")["default"]; - ImputMultiple: typeof import("./../components/ImputMultiple/index.vue")["default"]; - LangSelect: typeof import("./../components/LangSelect/index.vue")["default"]; - MapAdd: typeof import("./../components/MapAdd/index.vue")["default"]; - Pagination: typeof import("./../components/Pagination/index.vue")["default"]; - RouterLink: typeof import("vue-router")["RouterLink"]; - RouterView: typeof import("vue-router")["RouterView"]; - SvgIcon: typeof import("./../components/SvgIcon/index.vue")["default"]; - UnitSelect: typeof import("./../components/UnitSelect/index.vue")["default"]; + ElAlert: typeof import('element-plus/es')['ElAlert'] + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDrawer: typeof import('element-plus/es')['ElDrawer'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElProgress: typeof import('element-plus/es')['ElProgress'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] + ElUpload: typeof import('element-plus/es')['ElUpload'] + Hamburger: typeof import('./../components/Hamburger/index.vue')['default'] + IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default'] + IEpDownload: typeof import('~icons/ep/download')['default'] + IEpMoreFilled: typeof import('~icons/ep/more-filled')['default'] + IEpRefresh: typeof import('~icons/ep/refresh')['default'] + IEpUserFilled: typeof import('~icons/ep/user-filled')['default'] + ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default'] + LangSelect: typeof import('./../components/LangSelect/index.vue')['default'] + MapAdd: typeof import('./../components/MapAdd/index.vue')['default'] + Pagination: typeof import('./../components/Pagination/index.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default'] + UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default'] } export interface ComponentCustomProperties { - vLoading: typeof import("element-plus/es")["ElLoadingDirective"]; + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] } } diff --git a/apps/frontend/src/views/config/list/index.vue b/apps/frontend/src/views/config/list/index.vue index 9465c8a..c159682 100644 --- a/apps/frontend/src/views/config/list/index.vue +++ b/apps/frontend/src/views/config/list/index.vue @@ -24,16 +24,6 @@ :model="dataForm" label-position="top" > - - - { dataFormRef.value.validate((valid: boolean) => { if (valid) { let configs: ConfigsUpdateDto[] = [ - { - key: hysteria2TrafficTimeKey, - value: state.dataForm.hysteria2TrafficTime, - }, { key: resetTrafficCronKey, value: state.dataForm.resetTrafficCron, @@ -143,13 +130,11 @@ const submitForm = () => { const setConfig = async () => { const { data } = await listConfigApi({ - keys: [hysteria2TrafficTimeKey, resetTrafficCronKey], + keys: [resetTrafficCronKey], }); data.forEach((configVo) => { - if (configVo.key === hysteria2TrafficTimeKey) { - state.dataForm.hysteria2TrafficTime = configVo.value; - } else if (configVo.key === resetTrafficCronKey) { + if (configVo.key === resetTrafficCronKey) { state.dataForm.resetTrafficCron = configVo.value; } }); diff --git a/apps/frontend/src/views/hysteria/list/index.vue b/apps/frontend/src/views/hysteria/list/index.vue index 5709652..f66928d 100644 --- a/apps/frontend/src/views/hysteria/list/index.vue +++ b/apps/frontend/src/views/hysteria/list/index.vue @@ -51,16 +51,6 @@ :disabled="true" > - - - - - - - (["tcp", "udp", "tls", "https"]); const masqueradeTypes = ref(["file", "proxy", "string"]); const state = reactive({ - configForm: { - remark: "", - }, dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig, - activeName: "extension", + activeName: "listen", tlsType: "acme", aclType: "inline", obfs: false, @@ -1143,7 +1131,6 @@ const state = reactive({ }); const { - configForm, activeName, dataForm, tlsType, @@ -1183,17 +1170,6 @@ const handleExport = async () => { }; const setConfig = () => { - listConfigApi({ - keys: [hysteria2Remark], - }).then((response) => { - const data = response.data; - data.forEach((configVo) => { - if (configVo.key === hysteria2Remark) { - state.configForm.remark = configVo.value; - } - }); - }); - getHysteria2ConfigApi().then((response) => { const data = response.data; if (data) { diff --git a/apps/go.mod b/apps/go.mod index fd950da..00bc41f 100644 --- a/apps/go.mod +++ b/apps/go.mod @@ -1,67 +1,73 @@ module hy2xs-admin -go 1.21 +// Языковая версия держится ниже GO_VERSION из versions.env: сборка проверяет +// это шагом verify_go_toolchain_contract. 1.21 здесь стояла с поколения, в +// котором она ещё поддерживалась; к v1 её линия давно вне поддержки, а вместе +// с ней в бинарь попадала соответствующая stdlib. +go 1.25.0 require ( github.com/didip/tollbooth v4.0.2+incompatible - github.com/gin-gonic/gin v1.9.1 + github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 - github.com/go-playground/validator/v10 v10.14.0 - github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/go-playground/validator/v10 v10.30.3 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil v3.21.11+incompatible - github.com/sirupsen/logrus v1.9.3 - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/spf13/cobra v1.8.1 - golang.org/x/crypto v0.18.0 + github.com/sirupsen/logrus v1.10.2 + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.55.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 - gorm.io/gorm v1.25.9 + gorm.io/gorm v1.31.2 ) require ( - github.com/bytedance/sonic v1.9.1 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.3 // indirect + github.com/bytedance/sonic/loader v0.5.2 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/glebarez/go-sqlite v1.23.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/google/go-cmp v0.5.9 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/tklauser/go-sysconf v0.3.13 // indirect - github.com/tklauser/numcpus v0.7.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.11 // indirect + github.com/ugorji/go/codec v1.3.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/arch v0.3.0 // indirect - golang.org/x/net v0.20.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/time v0.5.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect + golang.org/x/arch v0.30.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.57.0 // indirect ) diff --git a/apps/go.sum b/apps/go.sum index 7f28e59..1ad0139 100644 --- a/apps/go.sum +++ b/apps/go.sum @@ -1,11 +1,12 @@ -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= -github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY= +github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -14,39 +15,42 @@ github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7z github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI= +github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -55,106 +59,139 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4= -github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0= -github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4= -github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= +github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= -golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w= +go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU= +golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= -gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/apps/middleware/cron.go b/apps/middleware/cron.go deleted file mode 100644 index 7c8a8ec..0000000 --- a/apps/middleware/cron.go +++ /dev/null @@ -1,44 +0,0 @@ -package middleware - -import ( - "errors" - "github.com/robfig/cron/v3" - "github.com/sirupsen/logrus" - "hy2xs-admin/dao" - "hy2xs-admin/model/constant" - "hy2xs-admin/service" - "time" -) - -func InitCron() error { - loc := time.Now().Location() - c := cron.New(cron.WithLocation(loc)) - _, err := c.AddFunc("@every 30s", service.CronHandleAccount) - if err != nil { - logrus.Errorf("cron add func CronHandleAccount err: %v", err) - return errors.New("cron add func CronHandleAccount err") - } - _, err = c.AddFunc("@every 10s", service.CollectMetricsSnapshot) - if err != nil { - logrus.Errorf("cron add func CollectMetricsSnapshot err: %v", err) - return errors.New("cron add func CollectMetricsSnapshot err") - } - _, err = c.AddFunc("@every 1h", service.CleanupStatsRetention) - if err != nil { - logrus.Errorf("cron add func CleanupStatsRetention err: %v", err) - return errors.New("cron add func CleanupStatsRetention err") - } - resetTrafficCron, err := dao.GetConfig("key = ?", constant.ResetTrafficCron) - if err != nil { - return err - } - if *resetTrafficCron.Value != "" { - _, err := c.AddFunc(*resetTrafficCron.Value, service.CronResetTraffic) - if err != nil { - logrus.Errorf("cron add func CronResetTraffic err: %v", err) - } - } - go service.CleanupStatsRetention() - c.Start() - return nil -} diff --git a/apps/model/constant/config.go b/apps/model/constant/config.go index 35e5ef2..8df79b9 100644 --- a/apps/model/constant/config.go +++ b/apps/model/constant/config.go @@ -8,15 +8,27 @@ package constant // HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS // терминируется на внешнем слое. Панель записывала в SQLite тот же порт, // который ей передали аргументом, и тут же читала его обратно. +// Ключей HYSTERIA2_ENABLE, HYSTERIA2_CONFIG, HYSTERIA2_TRAFFIC_TIME и +// HYSTERIA2_CONFIG_REMARK здесь больше нет. Все четыре достались от предыдущего +// поколения панели и к v1 перестали чем-либо управлять: +// +// HYSTERIA2_ENABLE — жизненным циклом Hysteria владеет systemd, а +// единственным потребителем ключа была строка лога; +// HYSTERIA2_CONFIG — второй источник истины рядом с +// /etc/hysteria/config.yaml, причём читался ПЕРВЫМ; +// HYSTERIA2_TRAFFIC_TIME — настройка в панели без единого потребителя в +// рантайме: интервал сбора метрик задан в коде; +// HYSTERIA2_CONFIG_REMARK — пустая read-only строка, которую никто не писал. +// +// Пустой ключ, который нельзя изменить и который ни на что не влияет, — это не +// «задел на будущее», а состояние, которое приходится объяснять в документации +// и защищать в allowlist. Настоящую замену получил только remark: имя профиля +// в share URI теперь выводится из имени пира (см. service/hysteria2_api.go). const ( JwtSecret = "JWT_SECRET" PeerSecretKey = "PEER_SECRET_KEY" PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY" Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET" - Hysteria2Enable = "HYSTERIA2_ENABLE" - Hysteria2Config = "HYSTERIA2_CONFIG" - Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME" - Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK" ResetTrafficCron = "RESET_TRAFFIC_CRON" ) @@ -40,20 +52,18 @@ const ( var ( // publicReadableConfigKeys — настройки, которые панель показывает оператору. publicReadableConfigKeys = map[string]struct{}{ - Hysteria2TrafficTime: {}, - ResetTrafficCron: {}, - Hysteria2ConfigRemark: {}, + ResetTrafficCron: {}, } // publicWritableConfigKeys — подмножество читаемых, которые оператор // действительно редактирует в интерфейсе. // - // HYSTERIA2_CONFIG_REMARK сюда не входит: панель его только отображает. - // Право записи, которым никто не пользуется, — это attack surface без - // пользователя. + // После очистки мёртвых ключей оба списка совпали: единственная настройка, + // которой владеет панель, — расписание сброса счётчиков трафика. Списки + // намеренно не схлопнуты в один: читаемое и записываемое — разные права, и + // первый же ключ «показываем, но менять нельзя» вернёт разницу. publicWritableConfigKeys = map[string]struct{}{ - Hysteria2TrafficTime: {}, - ResetTrafficCron: {}, + ResetTrafficCron: {}, } ) @@ -89,8 +99,6 @@ func InternalConfigKeys() []string { PeerSecretKey, PeerSecretEncryptionKey, Hysteria2TrafficStatsSecret, - Hysteria2Enable, - Hysteria2Config, } } diff --git a/apps/model/constant/config_test.go b/apps/model/constant/config_test.go index f576364..5aa4221 100644 --- a/apps/model/constant/config_test.go +++ b/apps/model/constant/config_test.go @@ -34,29 +34,42 @@ func TestSecretKeysAreListedAsInternal(t *testing.T) { } func TestOperatorSettingsRemainAvailable(t *testing.T) { - for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron, Hysteria2ConfigRemark} { - if !IsPublicReadableConfigKey(key) { - t.Errorf("пользовательская настройка %s недоступна на чтение", key) - } + if !IsPublicReadableConfigKey(ResetTrafficCron) { + t.Errorf("пользовательская настройка %s недоступна на чтение", ResetTrafficCron) } - for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron} { - if !IsPublicWritableConfigKey(key) { - t.Errorf("пользовательская настройка %s недоступна на запись", key) + if !IsPublicWritableConfigKey(ResetTrafficCron) { + t.Errorf("пользовательская настройка %s недоступна на запись", ResetTrafficCron) + } +} + +// Ключи, удалённые как мёртвое состояние, не должны вернуться в API «по +// инерции» вместе с восстановлением константы. Проверка идёт по строковым +// литералам: соответствующих констант в коде уже нет, и появиться они не +// должны. +func TestRemovedKeysStayClosed(t *testing.T) { + for _, key := range []string{ + "HYSTERIA2_ENABLE", + "HYSTERIA2_CONFIG", + "HYSTERIA2_TRAFFIC_TIME", + "HYSTERIA2_CONFIG_REMARK", + } { + if IsPublicReadableConfigKey(key) { + t.Errorf("удалённый ключ %s снова доступен на чтение", key) + } + if IsPublicWritableConfigKey(key) { + t.Errorf("удалённый ключ %s снова доступен на запись", key) } } } -// Право записи без потребителя — это attack surface без пользователя. -// HYSTERIA2_CONFIG_REMARK панель только показывает. +// Право записи без права чтения означает настройку, которую оператор может +// изменить, но не может увидеть. func TestWritableIsSubsetOfReadable(t *testing.T) { for _, key := range PublicWritableConfigKeys() { if !IsPublicReadableConfigKey(key) { t.Errorf("ключ %s можно записать, но нельзя прочитать", key) } } - if IsPublicWritableConfigKey(Hysteria2ConfigRemark) { - t.Error("HYSTERIA2_CONFIG_REMARK не редактируется в панели и не должен быть записываемым") - } } func TestUnknownKeyIsClosedByDefault(t *testing.T) { diff --git a/apps/service/admin_user.go b/apps/service/admin_user.go index 4189eb7..f2696ca 100644 --- a/apps/service/admin_user.go +++ b/apps/service/admin_user.go @@ -30,16 +30,9 @@ func Login(username string, plainPassword string) (string, bool, error) { if err != nil { return "", false, err } - verified, legacy := util.VerifyPassword(plainPassword, *admin.PasswordHash) - if !verified { + if !util.VerifyPassword(plainPassword, *admin.PasswordHash) { return "", false, errors.New(constant.WrongPassword) } - if legacy { - hash, hashErr := util.HashPassword(plainPassword) - if hashErr == nil { - _ = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{"password_hash": hash}) - } - } tokenVersion := int64(1) if admin.TokenVersion != nil && *admin.TokenVersion > 0 { tokenVersion = *admin.TokenVersion @@ -49,21 +42,33 @@ func Login(username string, plainPassword string) (string, bool, error) { if tokenErr != nil { return "", false, tokenErr } - requirePasswordChange := legacy + // Признак «пароль в старом формате, заставить сменить» отсюда убран вместе + // с поддержкой самого формата: единственный источник этого требования — + // флаг force_password_change, который ставит установка. + requirePasswordChange := false if admin.ForcePasswordChange != nil { requirePasswordChange = *admin.ForcePasswordChange != 0 } return token, requirePasswordChange, nil } +// GetAdminInfo читает учётные данные ТОЛЬКО из контекста запроса. +// +// Резервной ветки «если в контексте пусто — разобрать токен самостоятельно» +// здесь больше нет. Она была недостижима: все маршруты, ведущие сюда, стоят за +// middleware.JWTHandler, который разбирает токен, сверяет token_version и +// кладёт claims в контекст. При этом ветка несла собственный разбор заголовка +// Authorization через service.GetToken, где strings.SplitN(...)[1] паниковал +// на заголовке без пробела. +// +// Хуже недостижимости было бы её оживление: этот путь не проверял ни статус +// учётной записи, ни версию токена, то есть отозванный токен прошёл бы здесь, +// но не прошёл бы через middleware. Второй способ аутентификации, слабее +// первого, — не запасной вариант, а обход. func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) { claims, ok := adminClaimsFromContext(c) if !ok { - myClaims, err := ParseToken(GetToken(c)) - if err != nil { - return vo.AdminInfoVo{}, err - } - claims = myClaims.Admin + return vo.AdminInfoVo{}, errors.New(constant.UnauthorizedError) } admin, err := dao.GetAdminUser("id = ?", claims.Id) if err != nil { @@ -92,8 +97,7 @@ func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string) if admin.PasswordHash == nil { return errors.New(constant.SysError) } - verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash) - if !verified { + if !util.VerifyPassword(oldPassword, *admin.PasswordHash) { return errors.New(constant.WrongPassword) } hash, hashErr := util.HashPassword(newPassword) diff --git a/apps/service/config.go b/apps/service/config.go index 737f93a..1bbe369 100644 --- a/apps/service/config.go +++ b/apps/service/config.go @@ -15,10 +15,53 @@ import ( "strings" ) +// ConfigUpdate — одна пара «ключ/значение» из партии операторского API. +type ConfigUpdate struct { + Key string + Value string +} + func UpdateConfig(key string, value string) error { return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value}) } +// UpdateConfigs применяет партию настроек целиком или не применяет вовсе. +// +// Проверка значений в неё не входит намеренно: партия обязана быть проверена +// ПОЛНОСТЬЮ до открытия транзакции. Транзакция, внутри которой ещё может +// возникнуть отказ по содержимому, — это откат вместо отказа, то есть лишняя +// работа с базой на каждом неверно заполненном поле формы. +func UpdateConfigs(updates []ConfigUpdate) error { + if len(updates) == 0 { + return nil + } + return dao.WithConfigTx(func(tx dao.ConfigTx) error { + for _, item := range updates { + if err := tx.UpdateConfig([]string{item.Key}, map[string]interface{}{"value": item.Value}); err != nil { + return err + } + } + return nil + }) +} + +// ValidateConfigValue проверяет ЗНАЧЕНИЕ настройки до записи в базу. +// +// Раньше такой проверки не было вовсе: DTO ограничивал длину 128 символами, а +// поле расписания в панели — el-select с allow-create, то есть строка +// произвольная. Невалидное cron-выражение сохранялось, API отвечал успехом, а +// планировщик при следующем старте просто логировал ошибку и продолжал без +// джобы. Для оператора это выглядело как «настройка принята, сброс трафика +// молча перестал работать». +func ValidateConfigValue(key string, value string) error { + switch key { + case constant.ResetTrafficCron: + return ValidateResetTrafficCron(value) + default: + return nil + } +} + func GetConfig(key string) (entity.Config, error) { return dao.GetConfig("key = ?", key) } @@ -27,23 +70,26 @@ func ListConfig(keys []string) ([]entity.Config, error) { return dao.ListConfig("key in ?", keys) } +// GetHysteria2Config читает ТОЛЬКО /etc/hysteria/config.yaml. +// +// Ветки «сначала посмотреть в SQLite, а если пусто — в файл» здесь больше нет. +// Ключ HYSTERIA2_CONFIG остался от предыдущего поколения, где панель сама +// владела серверным конфигом. В HY2XS владелец ровно один — install-оркестратор, +// маршруты записи конфига удалены, и писателя у этого ключа не осталось: clean +// install создавал его пустым, и пустым он оставался навсегда. +// +// Опасна была не бесполезность, а приоритет: БД читалась ПЕРВОЙ. Любое +// значение, попавшее в неё в обход продукта, молча становилось тем, что панель +// показывает оператору и из чего генерирует клиентские ссылки, пока Hysteria +// продолжала работать по файлу. То есть ключ был готовым вторым источником +// истины — ровно тем, что проект последовательно убирает. func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) { var serverConfig bo.Hysteria2ServerConfig - config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) + content, err := os.ReadFile(constant.Hysteria2ConfigPath) if err != nil { return serverConfig, err } - if config.Value == nil || strings.TrimSpace(*config.Value) == "" { - content, readErr := os.ReadFile(constant.Hysteria2ConfigPath) - if readErr != nil { - return serverConfig, readErr - } - if err = yaml.Unmarshal(content, &serverConfig); err != nil { - return serverConfig, err - } - return serverConfig, nil - } - if err = yaml.Unmarshal([]byte(*config.Value), &serverConfig); err != nil { + if err = yaml.Unmarshal(content, &serverConfig); err != nil { return serverConfig, err } return serverConfig, nil @@ -54,15 +100,18 @@ func GetHysteria2ApiPort() (int64, error) { if err != nil { return 0, err } + // Сообщение передаётся аргументом, а не форматной строкой: значение + // приходит из /etc/hysteria/config.yaml, и знак процента в нём превращал + // запись журнала в мусор вида `%!s(MISSING)`. if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil { errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil" - logrus.Errorf(errMsg) + logrus.Errorf("%s", errMsg) return 0, errors.New(errMsg) } apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen) if err != nil { errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err) - logrus.Errorf(errMsg) + logrus.Errorf("%s", errMsg) return 0, errors.New(errMsg) } return apiPort, nil diff --git a/apps/service/cron_scheduler.go b/apps/service/cron_scheduler.go new file mode 100644 index 0000000..1388cef --- /dev/null +++ b/apps/service/cron_scheduler.go @@ -0,0 +1,244 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/robfig/cron/v3" + "github.com/sirupsen/logrus" + "hy2xs-admin/dao" + "hy2xs-admin/model/constant" +) + +// Планировщик принадлежит ПРОЦЕССУ, а не HTTP-серверу. +// +// Что было. InitCron жил в middleware, вызывался из runServer и на каждом +// вызове создавал новый cron.New(). Ссылка на планировщик никуда не +// сохранялась, а releaseResource() закрывал только SQLite и Hysteria — +// cron.Stop() не вызывался нигде. При этом смена RESET_TRAFFIC_CRON делала +// StopServer(), из-за чего runServer возвращался, а точка входа в cmd.go +// крутила его в `for {}` и запускала всё заново. +// +// Итог: каждая смена расписания добавляла ЦЕЛЫЙ дублирующий набор джоб, а +// старое расписание сброса трафика продолжало работать. После двух правок +// «@monthly → @weekly → @monthly» на процессе висели три планировщика, +// утроенный CollectMetricsSnapshot и три разных расписания сброса +// одновременно. Плюс окно, в котором джобы старого планировщика били в уже +// закрытое соединение SQLite: releaseResource() отрабатывал раньше, чем +// следующий runServer успевал открыть базу. +// +// Правильная граница: фиксированные джобы регистрируются один раз за жизнь +// процесса, а расписание сброса трафика перепланируется на месте по своему +// EntryID. HTTP-сервер к смене настройки отношения не имеет вообще. + +// cronShutdownTimeout ограничивает ожидание уже запущенных джоб при остановке. +// systemd по умолчанию даёт юниту 90 секунд, так что запас есть. +const cronShutdownTimeout = 10 * time.Second + +var ( + cronMu sync.Mutex + cronScheduler *cron.Cron + resetTrafficEntryID cron.EntryID + + // cronOwnedJobs учитывает джобы, запущенные ВНЕ расписания. + // + // Стартовая уборка статистики намеренно выполняется в фоне: на большой базе + // она заметно долгая, и держать на ней запуск сервиса незачем. Но + // cron.Stop() ждёт только то, что запустил сам планировщик, поэтому + // необслуженная горутина переживала закрытие SQLite — ровно та же болезнь, + // от которой лечится весь этот файл, только меньшего масштаба. + cronOwnedJobs sync.WaitGroup +) + +// ValidateResetTrafficCron проверяет выражение ТЕМ ЖЕ парсером, которым его +// потом будет разбирать runtime. +// +// cron.New() без опций собирает parser из Minute|Hour|Dom|Month|Dow|Descriptor, +// и ровно его же использует cron.ParseStandard. Поэтому «валидно на входе API» +// и «планируется в рантайме» здесь не могут разойтись — а разойтись они могли +// бы, если бы валидация была написана собственной регуляркой. +// +// Пустая строка — легальное значение и означает «автоматический сброс +// выключен»: в панели поле clearable, и оператор имеет право его очистить. +func ValidateResetTrafficCron(expression string) error { + trimmed := strings.TrimSpace(expression) + if trimmed == "" { + return nil + } + if _, err := cron.ParseStandard(trimmed); err != nil { + return fmt.Errorf("%s: невалидное cron-выражение %q: %v", constant.ResetTrafficCron, expression, err) + } + return nil +} + +// InitCron поднимает единственный планировщик процесса. +func InitCron() error { + cronMu.Lock() + defer cronMu.Unlock() + + if cronScheduler != nil { + return errors.New("cron scheduler is already running") + } + + c := cron.New(cron.WithLocation(time.Now().Location())) + + fixedJobs := []struct { + name string + spec string + job func() + }{ + {"CronHandleAccount", "@every 30s", CronHandleAccount}, + {"CollectMetricsSnapshot", "@every 10s", CollectMetricsSnapshot}, + {"CleanupStatsRetention", "@every 1h", CleanupStatsRetention}, + } + for _, fixed := range fixedJobs { + if _, err := c.AddFunc(fixed.spec, fixed.job); err != nil { + logrus.Errorf("cron add func %s err: %v", fixed.name, err) + return fmt.Errorf("cron add func %s err", fixed.name) + } + } + + expression, err := storedResetTrafficCron() + if err != nil { + return err + } + if expression != "" { + id, addErr := c.AddFunc(expression, CronResetTraffic) + if addErr != nil { + // Старт НЕ прерывается, и это осознанное решение. + // + // Панель отдаёт не только операторский UI: на ней же висит + // /internal/hysteria/auth, куда Hysteria ходит при каждом + // подключении пира. Отказ старта из-за испорченной строки + // расписания положил бы подключения пользователей — цена + // несопоставима с отключённым плановым сбросом счётчиков. + // + // Молчаливой деградации при этом нет: запись через API теперь + // валидируется тем же парсером, поэтому попасть сюда можно только + // правкой базы в обход продукта, и об этом пишется ERROR. + logrus.Errorf( + "cron: сохранённое %s=%q невалидно (%v); плановый сброс трафика выключен до исправления настройки", + constant.ResetTrafficCron, expression, addErr, + ) + } else { + resetTrafficEntryID = id + } + } + + cronOwnedJobs.Add(1) + go func() { + defer cronOwnedJobs.Done() + CleanupStatsRetention() + }() + + c.Start() + cronScheduler = c + return nil +} + +// RescheduleResetTraffic переносит джобу сброса трафика на новое расписание. +// +// HTTP-сервер здесь не участвует. Раньше единственным способом применить новое +// расписание был перезапуск процесса через StopServer(), и именно он и плодил +// планировщики. +func RescheduleResetTraffic(expression string) error { + if err := ValidateResetTrafficCron(expression); err != nil { + return err + } + + cronMu.Lock() + defer cronMu.Unlock() + + if cronScheduler == nil { + return errors.New("cron scheduler is not running") + } + + // Старая запись снимается всегда, даже если новая не будет добавлена: + // пустое выражение означает «сброс выключен», а не «оставить как было». + if resetTrafficEntryID != 0 { + cronScheduler.Remove(resetTrafficEntryID) + resetTrafficEntryID = 0 + } + + trimmed := strings.TrimSpace(expression) + if trimmed == "" { + return nil + } + + // Ошибка здесь уже невозможна: выражение прошло тот же парсер выше. + // Проверка остаётся, чтобы расхождение двух парсеров не превратилось в + // молча пропавшую джобу. + id, err := cronScheduler.AddFunc(trimmed, CronResetTraffic) + if err != nil { + return fmt.Errorf("%s: не удалось запланировать %q: %v", constant.ResetTrafficCron, expression, err) + } + resetTrafficEntryID = id + return nil +} + +// StopCron останавливает планировщик и дожидается уже запущенных джоб. +// +// Без этого джобы продолжали работать после закрытия SQLite: каждая из них +// ходит в базу, и остановка в обратном порядке (сначала планировщик, потом +// соединение) — часть контракта завершения процесса. +func StopCron() { + cronMu.Lock() + scheduler := cronScheduler + cronScheduler = nil + resetTrafficEntryID = 0 + cronMu.Unlock() + + if scheduler == nil { + return + } + + // Дожидаемся обеих групп: и джоб, запущенных планировщиком, и фоновых, + // которые он не видит. Незавершённая джоба означает работу с базой, которую + // releaseResource закроет сразу после возврата отсюда. + drained := make(chan struct{}) + go func() { + <-scheduler.Stop().Done() + cronOwnedJobs.Wait() + close(drained) + }() + + select { + case <-drained: + case <-time.After(cronShutdownTimeout): + logrus.Warnf("cron: запущенные джобы не завершились за %s, продолжаем остановку", cronShutdownTimeout) + } +} + +// ResetTrafficScheduled сообщает, запланирован ли сейчас сброс трафика. +// Существует ради тестов: иначе проверить, что старая запись действительно +// снята, а не просто добавлена рядом, можно было бы только по времени. +func ResetTrafficScheduled() bool { + cronMu.Lock() + defer cronMu.Unlock() + return resetTrafficEntryID != 0 +} + +// CronEntryCount возвращает число активных записей планировщика. +// Тоже ради тестов: накопление джоб — это именно рост этого числа. +func CronEntryCount() int { + cronMu.Lock() + defer cronMu.Unlock() + if cronScheduler == nil { + return 0 + } + return len(cronScheduler.Entries()) +} + +func storedResetTrafficCron() (string, error) { + config, err := dao.GetConfig("key = ?", constant.ResetTrafficCron) + if err != nil { + return "", err + } + if config.Value == nil { + return "", nil + } + return strings.TrimSpace(*config.Value), nil +} diff --git a/apps/service/cron_scheduler_test.go b/apps/service/cron_scheduler_test.go new file mode 100644 index 0000000..07f6451 --- /dev/null +++ b/apps/service/cron_scheduler_test.go @@ -0,0 +1,197 @@ +package service + +import ( + "testing" + + "hy2xs-admin/dao" + "hy2xs-admin/model/constant" +) + +// Число фиксированных джоб планировщика: CronHandleAccount, +// CollectMetricsSnapshot, CleanupStatsRetention. Записано числом намеренно — +// тест обязан заметить и появление новой джобы, и потерю существующей. +const fixedCronJobCount = 3 + +func startTestCron(t *testing.T, resetTrafficExpression string) { + t.Helper() + + newTestDB(t) + if err := dao.UpdateConfig( + []string{constant.ResetTrafficCron}, + map[string]interface{}{"value": resetTrafficExpression}, + ); err != nil { + t.Fatalf("не удалось записать %s: %v", constant.ResetTrafficCron, err) + } + + if err := InitCron(); err != nil { + t.Fatalf("InitCron: %v", err) + } + t.Cleanup(StopCron) +} + +// Главная регрессия P0: смена расписания не имеет права добавлять джобы. +// +// Раньше применение новой настройки шло через StopServer() и повторный +// runServer, а InitCron на каждом вызове создавал новый cron.New() и нигде его +// не сохранял. Старый планировщик никто не останавливал, поэтому после каждой +// правки на процессе оставался ЦЕЛЫЙ дублирующий набор джоб, а старое +// расписание сброса трафика продолжало срабатывать. +func TestRescheduleResetTrafficDoesNotAccumulateJobs(t *testing.T) { + startTestCron(t, "@monthly") + + initial := CronEntryCount() + if initial != fixedCronJobCount+1 { + t.Fatalf("после старта ожидалось %d записей, получено %d", fixedCronJobCount+1, initial) + } + + for _, expression := range []string{"@weekly", "@monthly", "0 3 * * *", "@daily"} { + if err := RescheduleResetTraffic(expression); err != nil { + t.Fatalf("RescheduleResetTraffic(%q): %v", expression, err) + } + if count := CronEntryCount(); count != initial { + t.Fatalf("после смены расписания на %q записей стало %d, ожидалось %d", expression, count, initial) + } + } +} + +// Пустое выражение означает «автоматический сброс выключен», а не «оставить +// как было»: поле в панели clearable, и оператор имеет право его очистить. +func TestRescheduleResetTrafficWithEmptyExpressionDisablesJob(t *testing.T) { + startTestCron(t, "@monthly") + + if !ResetTrafficScheduled() { + t.Fatal("после старта с непустым расписанием джоба сброса не запланирована") + } + + if err := RescheduleResetTraffic(" "); err != nil { + t.Fatalf("RescheduleResetTraffic(пусто): %v", err) + } + if ResetTrafficScheduled() { + t.Fatal("пустое выражение не сняло джобу сброса трафика") + } + if count := CronEntryCount(); count != fixedCronJobCount { + t.Fatalf("после отключения сброса ожидалось %d записей, получено %d", fixedCronJobCount, count) + } + + // Включение обратно должно работать без перезапуска процесса. + if err := RescheduleResetTraffic("@weekly"); err != nil { + t.Fatalf("повторное включение: %v", err) + } + if !ResetTrafficScheduled() { + t.Fatal("джоба сброса не вернулась после повторного включения") + } +} + +// Невалидное выражение не имеет права ни изменить планировщик, ни пройти +// молча: раньше ошибка AddFunc только логировалась, и сброс трафика тихо +// исчезал. +func TestRescheduleResetTrafficRejectsInvalidExpression(t *testing.T) { + startTestCron(t, "@monthly") + + before := CronEntryCount() + + if err := RescheduleResetTraffic("какая-нибудь ерунда"); err == nil { + t.Fatal("невалидное выражение принято") + } + if after := CronEntryCount(); after != before { + t.Fatalf("отклонённое выражение изменило планировщик: было %d записей, стало %d", before, after) + } + if !ResetTrafficScheduled() { + t.Fatal("отклонённое выражение сняло действующую джобу") + } +} + +// Валидация обязана пользоваться ТЕМ ЖЕ парсером, что и планировщик. Набор +// проверяет обе стороны: то, что cron умеет, должно приниматься, а то, что не +// умеет, — отклоняться до записи в базу. +func TestValidateResetTrafficCronMatchesSchedulerParser(t *testing.T) { + valid := []string{ + "", + " ", + "@monthly", + "@weekly", + "@daily", + "@hourly", + "@every 1h30m", + "0 0 * * *", + "*/15 * * * *", + "0 3 1 * 1", + } + for _, expression := range valid { + if err := ValidateResetTrafficCron(expression); err != nil { + t.Errorf("валидное выражение %q отклонено: %v", expression, err) + } + } + + invalid := []string{ + "какая-нибудь ерунда", + "@yearly extra", + "* * * *", + "* * * * * *", + "99 * * * *", + "@every", + "@every банан", + "- - - - -", + } + for _, expression := range invalid { + if err := ValidateResetTrafficCron(expression); err == nil { + t.Errorf("невалидное выражение %q принято", expression) + } + } +} + +// Невалидное значение в базе не имеет права уронить старт: на панели висит +// /internal/hysteria/auth, и отказ старта из-за строки расписания положил бы +// подключения пользователей. +func TestInitCronSurvivesInvalidStoredExpression(t *testing.T) { + startTestCron(t, "совершенно невалидная строка") + + if ResetTrafficScheduled() { + t.Fatal("невалидное выражение из базы было запланировано") + } + if count := CronEntryCount(); count != fixedCronJobCount { + t.Fatalf("фиксированные джобы не поднялись: ожидалось %d записей, получено %d", fixedCronJobCount, count) + } + + // Оператор обязан иметь возможность починить настройку через API, без + // перезапуска сервиса. + if err := RescheduleResetTraffic("@weekly"); err != nil { + t.Fatalf("исправление расписания: %v", err) + } + if !ResetTrafficScheduled() { + t.Fatal("исправленное расписание не применилось") + } +} + +// Планировщик принадлежит процессу: второй InitCron без остановки первого — +// это ровно тот сценарий, который и создавал дубликаты. +func TestInitCronRefusesSecondScheduler(t *testing.T) { + startTestCron(t, "@monthly") + + if err := InitCron(); err == nil { + t.Fatal("второй планировщик поднялся поверх работающего") + } + if count := CronEntryCount(); count != fixedCronJobCount+1 { + t.Fatalf("отклонённый повторный старт изменил планировщик: %d записей", count) + } +} + +func TestStopCronIsIdempotent(t *testing.T) { + startTestCron(t, "@monthly") + + StopCron() + if count := CronEntryCount(); count != 0 { + t.Fatalf("после остановки осталось %d записей", count) + } + if ResetTrafficScheduled() { + t.Fatal("после остановки джоба сброса числится запланированной") + } + + // Второй вызов не должен паниковать: StopCron стоит в defer runServer и + // может быть вызван повторно на пути обработки ошибки. + StopCron() + + if err := RescheduleResetTraffic("@weekly"); err == nil { + t.Fatal("перепланирование на остановленном планировщике не отклонено") + } +} diff --git a/apps/service/hysteria2.go b/apps/service/hysteria2.go index cf20227..43d97a7 100644 --- a/apps/service/hysteria2.go +++ b/apps/service/hysteria2.go @@ -3,9 +3,6 @@ package service import ( "errors" - "github.com/sirupsen/logrus" - "hy2xs-admin/dao" - "hy2xs-admin/model/constant" "hy2xs-admin/util" ) @@ -13,19 +10,19 @@ import ( // Панель умеет только наблюдать состояние: функций Start/Stop/Restart и // смены версии здесь нет намеренно, а не «временно отключены». +// InitHysteria2 проверяет единственное, что панели действительно нужно от +// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и +// у которого спрашивает состояние Hysteria2IsRunning. +// +// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели, +// в которой панель сама запускала и останавливала Hysteria: clean install +// создавал его со значением "0", записать "1" было некому, а вся ветка +// сводилась к строке в журнале. Настройка, которая ничем не управляет, но +// выглядит выключателем, хуже отсутствующей. func InitHysteria2() error { if !util.Exists(util.GetHysteria2BinPath()) { return errors.New("systemd-managed hysteria binary not found") } - - config, err := dao.GetConfig("key = ?", constant.Hysteria2Enable) - if err != nil { - return err - } - - if *config.Value == "1" { - logrus.Infof("hysteria2 lifecycle is managed by systemd in HY2XS production package") - } return nil } diff --git a/apps/service/hysteria2_api.go b/apps/service/hysteria2_api.go index 0bdd982..4c5d8e5 100644 --- a/apps/service/hysteria2_api.go +++ b/apps/service/hysteria2_api.go @@ -148,15 +148,8 @@ func Hysteria2Url(accountId int64) (string, error) { if err != nil { return "", err } - hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark) - if err != nil { - return "", err - } - remark := "" - if hysteria2ConfigRemark.Value != nil { - remark = *hysteria2ConfigRemark.Value - } + remark := shareRemark(peer.Name, hostname) obfs := hysteria2Config.ObfsShare() sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname) @@ -172,6 +165,27 @@ func Hysteria2Url(accountId int64) (string, error) { return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil } +// shareRemark даёт имя профиля, которое клиент показывает в списке серверов. +// +// Раньше оно бралось из HYSTERIA2_CONFIG_REMARK — пустой строки в таблице +// `config`, которую clean install создавал один раз и которую никто никогда не +// записывал. То есть fragment у ссылки отсутствовал всегда, и все выданные +// ссылки выглядели в клиенте одинаково. +// +// Теперь значение выводится детерминированно из имени пира: у панели с +// несколькими пирами это ровно то различие, которое пользователю и нужно +// видеть, и оно не требует ни одной дополнительной настройки. Fallback на +// публичный хост нужен для пира без имени — база это допускает (name имеет +// DEFAULT ''), а ссылка без имени профиля хуже, чем ссылка с именем сервера. +func shareRemark(peerName *string, hostname string) string { + if peerName != nil { + if name := strings.TrimSpace(*peerName); name != "" { + return name + } + } + return hostname +} + // isShareableObfsType перечисляет типы обфускации, которые официальная // URI-схема Hysteria умеет передавать клиенту. func isShareableObfsType(obfsType string) bool { diff --git a/apps/service/hysteria2_export.go b/apps/service/hysteria2_export.go index a7042a5..f768bd7 100644 --- a/apps/service/hysteria2_export.go +++ b/apps/service/hysteria2_export.go @@ -8,7 +8,6 @@ import ( "gopkg.in/yaml.v3" - "hy2xs-admin/dao" "hy2xs-admin/model/constant" ) @@ -21,19 +20,17 @@ const RedactedPlaceholder = "" // Это отдельный слой от GetHysteria2Config намеренно: типизированная модель // отражает известные HY2XS поля и используется для отображения, а сырой YAML // нужен там, где нельзя потерять поля, о которых HY2XS пока не знает. +// +// Источник ровно один — файл. Ветка «сначала SQLite, потом файл» удалена +// вместе с ключом HYSTERIA2_CONFIG: см. GetHysteria2Config в service/config.go. +// Здесь у неё было дополнительное следствие — выгрузка «фактического +// серверного конфига» могла отдать оператору документ, по которому Hysteria +// никогда не работала. func GetRawHysteria2Config() (string, error) { - config, err := dao.GetConfig("key = ?", constant.Hysteria2Config) + content, err := os.ReadFile(constant.Hysteria2ConfigPath) if err != nil { return "", err } - if config.Value != nil && strings.TrimSpace(*config.Value) != "" { - return *config.Value, nil - } - - content, readErr := os.ReadFile(constant.Hysteria2ConfigPath) - if readErr != nil { - return "", readErr - } return string(content), nil } diff --git a/apps/service/journal.go b/apps/service/journal.go index 4fefcc1..b974069 100644 --- a/apps/service/journal.go +++ b/apps/service/journal.go @@ -4,8 +4,10 @@ import ( "bufio" "encoding/json" "fmt" + "hy2xs-admin/model/constant" "hy2xs-admin/model/vo" "hy2xs-admin/util" + "os" "strconv" "strings" "time" @@ -58,6 +60,22 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) { return result, len(result), nil } +// ExportAdminLog отдаёт журнал самой админки, вырезав из него секреты. +// +// Раньше этот файл выгружался как есть, через c.File: чужой журнал продукт +// санитизировал, а собственный — нет. Обоснования у асимметрии не было, а цена +// известна поимённо: пока bootstrap-пароль администратора печатался в лог +// warning'ом, обычная кнопка «выгрузить журнал» отдавала его открытым текстом. +// Сам warning убран (см. dao/sqlite.go), но защита от следующего такого места +// должна стоять и на выходе. +func ExportAdminLog() (string, error) { + content, err := os.ReadFile(constant.SystemLogPath) + if err != nil { + return "", err + } + return SanitizeLogText(string(content)), nil +} + // ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору. // // Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные diff --git a/apps/service/jwt.go b/apps/service/jwt.go index 6c37c11..6afbc6b 100644 --- a/apps/service/jwt.go +++ b/apps/service/jwt.go @@ -2,59 +2,110 @@ package service import ( "errors" - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" "hy2xs-admin/dao" "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" - "strings" - "time" ) +// Токены подписываются и проверяются библиотекой golang-jwt/jwt/v5. +// +// Переход с v3 не косметический. У `github.com/golang-jwt/jwt` v3.2.2 есть +// GO-2025-3553 (чрезмерное выделение памяти при разборе заголовка), и у неё +// НЕТ исправленной версии в ветке v3: `Fixed in: N/A`. Уязвимый код +// достигается из ParseToken, то есть с неаутентифицированного запроса — любой, +// кто может обратиться к панели, попадает в него до всякой проверки прав. +// Обновить здесь было нечего, поэтому лечится только сменой мажорной ветки. +// +// Заодно закрыт второй, тихий недостаток прежней реализации: keyfunc возвращал +// ключ, НЕ проверяя алгоритм подписи. Разбор без ограничения набора алгоритмов +// — это тот класс ошибок, из которого растёт подмена алгоритма; ниже стоит +// jwt.WithValidMethods, и допустимый алгоритм ровно один. + const TokenExpireDuration = time.Hour * 24 +// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами, +// не должен разбираться как наш даже при совпадении ключа. +const tokenIssuer = "hy2xs-admin" + type MyClaims struct { Admin bo.AccountBo `json:"admin"` - jwt.StandardClaims + jwt.RegisteredClaims } -func GenToken(accountBo bo.AccountBo) (string, error) { - c := MyClaims{ - Admin: accountBo, - StandardClaims: jwt.StandardClaims{ - ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(), - Issuer: "hy2xs-admin", - }, - } - config, err := dao.GetConfig("key = ?", constant.JwtSecret) - if err != nil { - return "", errors.New(constant.SysError) - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) - return token.SignedString([]byte(*config.Value)) -} - -func ParseToken(tokenString string) (*MyClaims, error) { +func jwtSecret() ([]byte, error) { config, err := dao.GetConfig("key = ?", constant.JwtSecret) if err != nil { return nil, errors.New(constant.SysError) } - token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) { - return []byte(*config.Value), nil - }) - if err != nil { - return nil, errors.New(constant.IllegalTokenError) + if config.Value == nil || strings.TrimSpace(*config.Value) == "" { + // Подпись пустым ключом дала бы токены, которые подделывает кто угодно. + // Раньше значение разыменовывалось без проверки: пустой JWT_SECRET + // молча превращался в ключ нулевой длины. + return nil, errors.New(constant.SysError) } - if claims, ok := token.Claims.(*MyClaims); ok && token.Valid { - return claims, nil - } - return nil, errors.New(constant.TokenExpiredError) + return []byte(*config.Value), nil } -func GetToken(c *gin.Context) string { - tokenStr := c.Request.Header.Get("Authorization") - if tokenStr == "" { - return "" +func GenToken(accountBo bo.AccountBo) (string, error) { + secret, err := jwtSecret() + if err != nil { + return "", err } - return strings.SplitN(tokenStr, " ", 2)[1] + + now := time.Now() + claims := MyClaims{ + Admin: accountBo, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(TokenExpireDuration)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: tokenIssuer, + }, + } + + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) } + +func ParseToken(tokenString string) (*MyClaims, error) { + secret, err := jwtSecret() + if err != nil { + return nil, err + } + + token, err := jwt.ParseWithClaims( + tokenString, + &MyClaims{}, + func(*jwt.Token) (interface{}, error) { return secret, nil }, + // Допустимый алгоритм ровно один. Без этого ограничения набор + // алгоритмов задаёт сам токен, то есть недоверенный вход. + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithIssuer(tokenIssuer), + // Токен без срока жизни не должен считаться валидным бессрочным. + jwt.WithExpirationRequired(), + ) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, errors.New(constant.TokenExpiredError) + } + return nil, errors.New(constant.IllegalTokenError) + } + + claims, ok := token.Claims.(*MyClaims) + if !ok || !token.Valid { + return nil, errors.New(constant.IllegalTokenError) + } + return claims, nil +} + +// Функции GetToken здесь больше нет. +// +// Она доставала токен из заголовка Authorization вторым, независимым от +// middleware.JWTHandler способом — и делала это через +// strings.SplitN(tokenStr, " ", 2)[1], то есть паниковала на заголовке без +// пробела: `Authorization: abc` давал index out of range. Единственный +// потребитель — резервная ветка GetAdminInfo, которая была недостижима и +// проверяла меньше, чем middleware. Разбор токена у продукта ровно один. diff --git a/apps/service/jwt_test.go b/apps/service/jwt_test.go new file mode 100644 index 0000000..c952feb --- /dev/null +++ b/apps/service/jwt_test.go @@ -0,0 +1,204 @@ +package service + +import ( + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "hy2xs-admin/dao" + "hy2xs-admin/model/bo" + "hy2xs-admin/model/constant" +) + +func newJwtTestDB(t *testing.T) []byte { + t.Helper() + + newTestDB(t) + + secret := "test-jwt-secret-not-used-anywhere-else-0123456789" + if err := dao.UpsertConfigValue(constant.JwtSecret, secret); err != nil { + t.Fatalf("не удалось записать %s: %v", constant.JwtSecret, err) + } + return []byte(secret) +} + +func testAccount() bo.AccountBo { + return bo.AccountBo{Id: 1, Username: "hy2xsadmin", Roles: []string{"admin"}, TokenVersion: 1} +} + +func TestGenAndParseTokenRoundTrip(t *testing.T) { + newJwtTestDB(t) + + token, err := GenToken(testAccount()) + if err != nil { + t.Fatalf("GenToken: %v", err) + } + + claims, err := ParseToken(token) + if err != nil { + t.Fatalf("ParseToken: %v", err) + } + if claims.Admin.Id != 1 || claims.Admin.Username != "hy2xsadmin" { + t.Fatalf("claims не совпадают: %+v", claims.Admin) + } + if claims.Admin.TokenVersion != 1 { + t.Fatalf("token_version потерян: %d", claims.Admin.TokenVersion) + } + if claims.ExpiresAt == nil { + t.Fatal("токен выписан без срока жизни") + } +} + +// Алгоритм подписи задаётся сервером, а не токеном. +// +// Прежний keyfunc возвращал ключ, не глядя на token.Method: набор допустимых +// алгоритмов фактически определял недоверенный вход. Здесь проверяется, что +// токен, подписанный другим HMAC-алгоритмом тем же ключом, отклоняется. +func TestParseTokenRejectsOtherSigningMethod(t *testing.T) { + secret := newJwtTestDB(t) + + now := time.Now() + claims := MyClaims{ + Admin: testAccount(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: tokenIssuer, + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(secret) + if err != nil { + t.Fatalf("не удалось подписать тестовый токен: %v", err) + } + + if _, err := ParseToken(signed); err == nil { + t.Fatal("токен, подписанный HS512, принят при разрешённом только HS256") + } +} + +// Токен без exp не должен считаться бессрочно валидным. +func TestParseTokenRejectsTokenWithoutExpiration(t *testing.T) { + secret := newJwtTestDB(t) + + claims := MyClaims{ + Admin: testAccount(), + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: tokenIssuer, + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) + if err != nil { + t.Fatalf("не удалось подписать тестовый токен: %v", err) + } + + if _, err := ParseToken(signed); err == nil { + t.Fatal("токен без exp принят") + } +} + +func TestParseTokenRejectsExpiredToken(t *testing.T) { + secret := newJwtTestDB(t) + + past := time.Now().Add(-2 * time.Hour) + claims := MyClaims{ + Admin: testAccount(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(past.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(past), + Issuer: tokenIssuer, + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) + if err != nil { + t.Fatalf("не удалось подписать тестовый токен: %v", err) + } + + _, err = ParseToken(signed) + if err == nil { + t.Fatal("истёкший токен принят") + } + if err.Error() != constant.TokenExpiredError { + t.Errorf("истечение срока должно сообщаться отдельно, получено: %v", err) + } +} + +// Токен, выписанный не нами, не должен разбираться как наш даже при совпадении +// ключа: issuer входит в проверку. +func TestParseTokenRejectsForeignIssuer(t *testing.T) { + secret := newJwtTestDB(t) + + now := time.Now() + claims := MyClaims{ + Admin: testAccount(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "some-other-service", + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) + if err != nil { + t.Fatalf("не удалось подписать тестовый токен: %v", err) + } + + if _, err := ParseToken(signed); err == nil { + t.Fatal("токен с чужим issuer принят") + } +} + +func TestParseTokenRejectsWrongSecret(t *testing.T) { + newJwtTestDB(t) + + now := time.Now() + claims := MyClaims{ + Admin: testAccount(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: tokenIssuer, + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("совершенно другой ключ подписи")) + if err != nil { + t.Fatalf("не удалось подписать тестовый токен: %v", err) + } + + if _, err := ParseToken(signed); err == nil { + t.Fatal("токен, подписанный чужим ключом, принят") + } +} + +func TestParseTokenRejectsGarbage(t *testing.T) { + newJwtTestDB(t) + + for _, value := range []string{ + "", + "not-a-token", + "a.b.c", + strings.Repeat("A", 500), + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhZG1pbiI6eyJpZCI6MX19.", + } { + if _, err := ParseToken(value); err == nil { + t.Errorf("мусорный токен %q принят", value) + } + } +} + +// Пустой JWT_SECRET — повреждённое состояние, а не «ключ нулевой длины». +// Раньше значение разыменовывалось без проверки, и подпись выполнялась пустым +// ключом: такой токен подделывает кто угодно. +func TestGenTokenRefusesEmptySecret(t *testing.T) { + newTestDB(t) + if err := dao.UpsertConfigValue(constant.JwtSecret, ""); err != nil { + t.Fatalf("не удалось очистить %s: %v", constant.JwtSecret, err) + } + + if _, err := GenToken(testAccount()); err == nil { + t.Fatal("токен подписан пустым ключом") + } + if _, err := ParseToken("whatever"); err == nil { + t.Fatal("разбор токена с пустым ключом не отклонён") + } +} diff --git a/apps/service/peer.go b/apps/service/peer.go index a7c561b..7b06b54 100644 --- a/apps/service/peer.go +++ b/apps/service/peer.go @@ -46,10 +46,40 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) { return result, total, nil } +// Пир установщика защищён во ВСЕХ путях записи, а не только в импорте. +// +// Что было. ReservedBootstrapPeerName проверялся исключительно в +// applyPeerImportEntry. Обычный CRUD панели такой проверки не имел, то есть +// ровно то, ради чего guard существует, делалось через интерфейс: секрет +// bootstrap-admin-peer продублирован в /etc/hy2xs/bootstrap-admin.secret, +// откуда его читает проверка machine-auth оркестратора, и смена секрета через +// форму молча рассинхронизировала бы файл с базой. Дверей в таблицу было две, +// а замок висел на одной. +// +// Границы намеренно разные для разных операций: +// +// идентичность и секрет — запрещены: они и есть то, что дублируется в файле; +// удаление и отключение — разрешены: это осознанные действия оператора, +// результат которых виден в списке пиров. Bootstrap-пир после установки +// остаётся действующим доступом, и запрет его убрать означал бы вечный +// неотзываемый доступ. +var errBootstrapPeerIdentity = fmt.Errorf( + "пир %q принадлежит установщику: его имя и секрет продублированы в "+ + "/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+ + "Ненужный bootstrap-пир следует удалить целиком, а не переподписывать", + ReservedBootstrapPeerName, +) + func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { if peerDto.Name == nil || *peerDto.Name == "" { return vo.PeerVo{}, errors.New(constant.InvalidError) } + // Имя зарезервировано за установщиком даже когда сам пир уже удалён: + // иначе после удаления обычный пир мог бы занять это имя и оказаться под + // защитой, предназначенной не ему. + if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { + return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName) + } if ExistPeerName(*peerDto.Name, 0) { return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name)) } @@ -94,6 +124,10 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { } func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error { + if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil { + return err + } + updates := map[string]interface{}{} if peerDto.Name != nil && *peerDto.Name != "" { updates["name"] = *peerDto.Name @@ -128,6 +162,47 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error { return dao.UpdatePeer([]int64{id}, updates) } +// assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано +// в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя. +func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error { + // Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от + // проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир + // существует. + if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { + existing, err := dao.GetPeer("id = ?", id) + if err != nil { + return err + } + if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName { + return fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName) + } + } + + existing, err := dao.GetPeer("id = ?", id) + if err != nil { + return err + } + if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName { + return nil + } + + if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) != "" && + strings.TrimSpace(*peerDto.Name) != ReservedBootstrapPeerName { + return errBootstrapPeerIdentity + } + if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" { + return errBootstrapPeerIdentity + } + return nil +} + +// DeletePeer удаляет пира, включая bootstrap-пира установщика. +// +// Удаление намеренно НЕ запрещено. Bootstrap-пир после установки — обычный +// действующий доступ, секрет которого лежит ещё и в файле на диске; оператор +// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление +// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в +// списке. func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } func GetPeerVo(id int64) (vo.PeerVo, error) { diff --git a/apps/service/peer_bootstrap_guard_test.go b/apps/service/peer_bootstrap_guard_test.go new file mode 100644 index 0000000..951835b --- /dev/null +++ b/apps/service/peer_bootstrap_guard_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "strings" + "testing" + + "hy2xs-admin/dao" + "hy2xs-admin/model/dto" +) + +func strPtr(v string) *string { return &v } + +// Пир установщика защищён во всех путях записи, а не только в импорте. +// +// Раньше проверка ReservedBootstrapPeerName стояла исключительно в +// applyPeerImportEntry: секрет bootstrap-admin-peer продублирован в +// /etc/hy2xs/bootstrap-admin.secret, и смена его через обычную форму панели +// молча рассинхронизировала бы файл с базой. +func TestUpdatePeerRefusesToResecretBootstrapPeer(t *testing.T) { + newTestDB(t) + id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) + + before := snapshotPeers(t)[ReservedBootstrapPeerName] + + err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("attacker-chosen-secret")}) + if err == nil { + t.Fatal("смена секрета пира установщика не отклонена") + } + if !strings.Contains(err.Error(), ReservedBootstrapPeerName) { + t.Errorf("отказ не называет пира: %v", err) + } + + after := snapshotPeers(t)[ReservedBootstrapPeerName] + if after.SecretDigest == nil || before.SecretDigest == nil || *after.SecretDigest != *before.SecretDigest { + t.Fatal("секрет пира установщика изменился несмотря на отказ") + } +} + +func TestUpdatePeerRefusesToRenameBootstrapPeer(t *testing.T) { + newTestDB(t) + id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) + + if err := UpdatePeer(id, dto.PeerUpdateDto{Name: strPtr("renamed-peer")}); err == nil { + t.Fatal("переименование пира установщика не отклонено") + } + + if _, ok := snapshotPeers(t)[ReservedBootstrapPeerName]; !ok { + t.Fatal("пир установщика переименован несмотря на отказ") + } +} + +// Зарезервированное имя нельзя занять чужому пиру. UNIQUE(name) закрывает +// этот путь только пока bootstrap-пир существует, а его разрешено удалять. +func TestUpdatePeerRefusesToTakeReservedName(t *testing.T) { + newTestDB(t) + id := seedPeer(t, "ordinary-peer", "ordinary-auth-id") + + if err := UpdatePeer(id, dto.PeerUpdateDto{Name: strPtr(ReservedBootstrapPeerName)}); err == nil { + t.Fatal("переименование обычного пира в зарезервированное имя не отклонено") + } + + peers := snapshotPeers(t) + if _, ok := peers["ordinary-peer"]; !ok { + t.Fatal("обычный пир переименован несмотря на отказ") + } +} + +func TestCreatePeerRefusesReservedName(t *testing.T) { + newTestDB(t) + + name := ReservedBootstrapPeerName + quota := int64(-1) + if _, err := CreatePeer(dto.PeerSaveDto{Name: &name, QuotaBytes: "a}); err == nil { + t.Fatal("создание пира с зарезервированным именем не отклонено") + } +} + +// Изменения, не затрагивающие идентичность и секрет, обязаны проходить: иначе +// оператор не смог бы ни отключить bootstrap-пира, ни ограничить ему квоту. +func TestUpdatePeerAllowsNonIdentityChangesOnBootstrapPeer(t *testing.T) { + newTestDB(t) + id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) + + disabled := int64(1) + if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &disabled}); err != nil { + t.Fatalf("отключение пира установщика отклонено: %v", err) + } + + peer := snapshotPeers(t)[ReservedBootstrapPeerName] + if peer.Disabled == nil || *peer.Disabled != 1 { + t.Fatal("отключение не применилось") + } +} + +// Удаление разрешено намеренно: bootstrap-пир после установки — действующий +// доступ, секрет которого лежит ещё и в файле на диске, и оператор обязан +// иметь возможность его отозвать. Расхождения база/файл удаление не создаёт. +func TestDeletePeerAllowsRemovingBootstrapPeer(t *testing.T) { + newTestDB(t) + id := seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) + + if err := DeletePeer(id); err != nil { + t.Fatalf("удаление пира установщика отклонено: %v", err) + } + if _, err := dao.GetPeer("name = ?", ReservedBootstrapPeerName); err == nil { + t.Fatal("пир установщика остался в базе после удаления") + } +} diff --git a/apps/service/peer_secret.go b/apps/service/peer_secret.go index d245d6c..124fa04 100644 --- a/apps/service/peer_secret.go +++ b/apps/service/peer_secret.go @@ -1,59 +1,29 @@ package service import ( - "encoding/base64" - "errors" "strings" "hy2xs-admin/dao" - "hy2xs-admin/model/constant" - "hy2xs-admin/model/entity" "hy2xs-admin/util" ) -func getOrCreateConfigKey(key string, size int) (string, error) { - cfg, err := dao.GetConfig("key = ?", key) - if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" { - return strings.TrimSpace(*cfg.Value), nil - } - raw, genErr := util.RandomString(size) - if genErr != nil { - return "", genErr - } - value := raw - remark := key - if _, saveErr := dao.SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); saveErr != nil { - if updErr := dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value}); updErr != nil { - return "", updErr - } - } - return value, nil -} +// Криптоматериал пиров берётся из dao, а не создаётся здесь заново. +// +// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и +// getPeerSecretEncryptionKey — построчные копии одноимённых функций из +// dao/sqlite.go. Две реализации, порождающие один и тот же материал шифрования, +// в двух пакетах: bootstrap-пир получал ключ через dao, а всё остальное — через +// service. Пока обе читали один ключ из таблицы `config`, поведение совпадало, +// но любое расхождение означало бы, что секреты пиров шифруются одним ключом, а +// расшифровываются другим, и проявилось бы это на живых пирах. +// +// Копия в service несла ещё и порядок «сначала INSERT, при ошибке UPDATE», +// из-за которого каждая первая загрузка печатала в журнал +// `duplicated key not allowed` уровня error на здоровом старте. +// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира. func GetPeerSecretKey() (string, error) { - return getOrCreateConfigKey(constant.PeerSecretKey, 48) -} - -func getPeerSecretEncryptionKey() ([]byte, error) { - raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32) - if err != nil { - return nil, err - } - decoded, decErr := util.DecodeBase64Key(raw, 32) - if decErr == nil { - return decoded, nil - } - // legacy/plain bootstrap path: convert to stable base64 once - plain := []byte(strings.TrimSpace(raw)) - if len(plain) < 32 { - return nil, errors.New("invalid peer secret encryption key") - } - plain = plain[:32] - encoded := base64.StdEncoding.EncodeToString(plain) - if updErr := dao.UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); updErr != nil { - return nil, updErr - } - return plain, nil + return dao.GetOrCreatePeerSecretDigestKey() } func PeerSecretDigest(rawSecret string) (string, error) { @@ -65,7 +35,7 @@ func PeerSecretDigest(rawSecret string) (string, error) { } func EncryptPeerSecret(rawSecret string) (string, error) { - key, err := getPeerSecretEncryptionKey() + key, err := dao.GetOrCreatePeerSecretEncryptionKey() if err != nil { return "", err } @@ -76,7 +46,7 @@ func DecryptPeerSecret(stored string) (string, error) { if !strings.HasPrefix(stored, "v1:") { return stored, nil } - key, err := getPeerSecretEncryptionKey() + key, err := dao.GetOrCreatePeerSecretEncryptionKey() if err != nil { return "", err } diff --git a/apps/util/encrypt.go b/apps/util/encrypt.go index 7b20863..bf5482c 100644 --- a/apps/util/encrypt.go +++ b/apps/util/encrypt.go @@ -15,16 +15,22 @@ import ( "golang.org/x/crypto/bcrypt" ) -func SHA224String(password string) string { - hash := sha256.New224() - hash.Write([]byte(password)) - val := hash.Sum(nil) - str := "" - for _, v := range val { - str += fmt.Sprintf("%02x", v) - } - return str -} +// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней. +// +// Это был формат хранения паролей предыдущего поколения: несолёный SHA-224, +// то есть быстрый хеш общего назначения, по которому словарная атака идёт +// на порядки быстрее, чем по bcrypt, а одинаковые пароли дают одинаковые +// хеши. VerifyPassword принимал такие хеши как «legacy» и молча переписывал +// их в bcrypt при первом успешном входе. +// +// В HY2XS v1 такого хеша не может существовать. Единственные места, создающие +// учётную запись, — ensureSecureBootstrapAdmin и `reset-admin`, и оба пишут +// bcrypt; миграции 002/003, переносившие таблицу `account` предыдущего +// поколения, удалены; установка возможна только на чистый хост, а конфигурация +// 0.x отклоняется по HY2XS_CONFIG_SCHEMA_VERSION. То есть compatibility-ветка +// пережила ровно тот слой совместимости, ради которого существовала, и +// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике +// логина, доступном без аутентификации. func HashPassword(password string) (string, error) { if len(strings.TrimSpace(password)) < 6 { @@ -41,12 +47,16 @@ func IsBcryptHash(hash string) bool { return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") } -func VerifyPassword(password string, storedHash string) (ok bool, legacy bool) { - if IsBcryptHash(storedHash) { - err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) - return err == nil, false +// VerifyPassword проверяет пароль ровно против одного формата хранения. +// +// Хеш, который не является bcrypt, — это не «пароль в старом формате», а +// повреждённая или подменённая строка в базе, и правильный ответ на неё — +// отказ во входе. +func VerifyPassword(password string, storedHash string) bool { + if !IsBcryptHash(storedHash) { + return false } - return SHA224String(password) == storedHash, true + return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) == nil } func HmacSHA256Hex(payload string, secret string) string { diff --git a/apps/util/encrypt_test.go b/apps/util/encrypt_test.go index fb0f98d..afecd29 100644 --- a/apps/util/encrypt_test.go +++ b/apps/util/encrypt_test.go @@ -1,7 +1,100 @@ package util -import "testing" +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) -func TestSHA224String(t *testing.T) { - println(SHA224String("sysadmin")) +// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не +// проверял. Вместе с самой функцией он удалён: формат хранения паролей +// предыдущего поколения в v1 недостижим, а тест, не содержащий утверждений, +// создавал видимость покрытия. + +func TestHashPasswordProducesBcrypt(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if !IsBcryptHash(hash) { + t.Fatalf("HashPassword вернул не bcrypt: %q", hash) + } +} + +// Соль обязана делать два хеша одного пароля различными: именно этого не +// давал прежний несолёный SHA-224. +func TestHashPasswordIsSalted(t *testing.T) { + first, err := HashPassword("one password for both") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + second, err := HashPassword("one password for both") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if first == second { + t.Fatal("два хеша одного пароля совпали: соль не применяется") + } + if !VerifyPassword("one password for both", first) || !VerifyPassword("one password for both", second) { + t.Fatal("солёные хеши не проходят проверку") + } +} + +func TestHashPasswordRejectsShortPassword(t *testing.T) { + if _, err := HashPassword("12345"); err == nil { + t.Fatal("слишком короткий пароль принят") + } + if _, err := HashPassword(" "); err == nil { + t.Fatal("пароль из пробелов принят") + } +} + +func TestVerifyPasswordAcceptsOnlyCorrectPassword(t *testing.T) { + hash, err := HashPassword("s3cret-passphrase") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if !VerifyPassword("s3cret-passphrase", hash) { + t.Error("верный пароль отклонён") + } + if VerifyPassword("s3cret-passphras", hash) { + t.Error("неверный пароль принят") + } + if VerifyPassword("", hash) { + t.Error("пустой пароль принят") + } +} + +// Главная регрессия: несолёный SHA-224 из предыдущего поколения больше не +// является допустимым форматом хранения. Раньше VerifyPassword сравнивал с ним +// напрямую и возвращал признак legacy, то есть вход по такому хешу удавался. +func TestVerifyPasswordRejectsLegacySha224Hash(t *testing.T) { + password := "sysadmin" + + sum := sha256.Sum224([]byte(password)) + legacyHash := hex.EncodeToString(sum[:]) + + if VerifyPassword(password, legacyHash) { + t.Fatal("вход по несолёному SHA-224 всё ещё возможен") + } + if IsBcryptHash(legacyHash) { + t.Fatal("SHA-224 опознан как bcrypt") + } +} + +// Любая строка, не являющаяся bcrypt-хешем, — повреждённое или подменённое +// значение в базе, а не «другой формат». +func TestVerifyPasswordRejectsNonBcryptStorage(t *testing.T) { + for _, stored := range []string{ + "", + "plaintext-password", + "$1$abcdefgh$0123456789abcdef", + "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$hash", + strings.Repeat("a", 60), + } { + if VerifyPassword("plaintext-password", stored) { + t.Errorf("принят пароль против не-bcrypt значения %q", stored) + } + } } diff --git a/apps/util/rand.go b/apps/util/rand.go index 00bc91d..4c2f66b 100644 --- a/apps/util/rand.go +++ b/apps/util/rand.go @@ -1,19 +1,55 @@ package util -import "crypto/rand" +import ( + "crypto/rand" + "errors" +) const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +// rejectionThreshold — граница равномерности при отображении байта в алфавит. +// +// В алфавите 62 символа, а байт принимает 256 значений. 256 = 4×62 + 8, +// поэтому при прямом `charset[b % 62]` первые ВОСЕМЬ символов алфавита +// («a»…«h») выпадают пятью значениями байта, а остальные 54 — четырьмя. То +// есть распределение смещено примерно на четверть в пользу начала алфавита. +// +// Байты со значением 248 и выше отбрасываются: оставшиеся 248 = 4×62 делятся +// на длину алфавита нацело, и остаток становится равномерным. Цена — около 3% +// отброшенных байтов, то есть один лишний вызов rand.Read на несколько сотен +// символов. +// +// Практическая потеря энтропии у прежнего варианта была невелика, но это +// ЕДИНСТВЕННЫЙ генератор секретов продукта: через него проходят JWT_SECRET, +// PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY, секрет trafficStats API, +// секреты и auth_id пиров. Смещённое распределение в такой позиции чинится +// пятью строками и не обсуждается на каждом аудите заново. +const rejectionThreshold = 256 - (256 % len(charset)) + +// RandomString возвращает строку заданной длины из букв и цифр, равномерно +// распределённых по алфавиту. func RandomString(length int) (string, error) { - bytes := make([]byte, length) - _, err := rand.Read(bytes) - if err != nil { - return "", err + if length <= 0 { + return "", errors.New("random string length must be positive") } - for i := range bytes { - bytes[i] = charset[int(bytes[i])%len(charset)] + out := make([]byte, 0, length) + buf := make([]byte, length) + + for len(out) < length { + if _, err := rand.Read(buf); err != nil { + return "", err + } + for _, b := range buf { + if int(b) >= rejectionThreshold { + continue + } + out = append(out, charset[int(b)%len(charset)]) + if len(out) == length { + break + } + } } - return string(bytes), nil + return string(out), nil } diff --git a/apps/util/rand_test.go b/apps/util/rand_test.go new file mode 100644 index 0000000..1cd7ff2 --- /dev/null +++ b/apps/util/rand_test.go @@ -0,0 +1,93 @@ +package util + +import ( + "math" + "strings" + "testing" +) + +func TestRandomStringLengthAndAlphabet(t *testing.T) { + for _, length := range []int{1, 6, 18, 24, 32, 48, 64, 300} { + value, err := RandomString(length) + if err != nil { + t.Fatalf("RandomString(%d): %v", length, err) + } + if len(value) != length { + t.Errorf("RandomString(%d) вернул строку длины %d", length, len(value)) + } + for _, r := range value { + if !strings.ContainsRune(charset, r) { + t.Errorf("RandomString(%d) вернул символ %q вне алфавита", length, r) + } + } + } +} + +func TestRandomStringRejectsNonPositiveLength(t *testing.T) { + for _, length := range []int{0, -1} { + if _, err := RandomString(length); err == nil { + t.Errorf("RandomString(%d) не отклонён", length) + } + } +} + +// Проверка отсутствия modulo bias. +// +// Прежняя реализация делала charset[b % 62] по байту напрямую. 256 = 4×62 + 8, +// поэтому первые восемь символов алфавита выпадали пятью значениями байта из +// 256, а остальные 54 — четырьмя: перекос примерно на 25%. +// +// Тест сравнивает наблюдаемые частоты первых восьми символов алфавита с +// остальными. На выборке в 200 000 символов ожидаемая частота каждого — около +// 3226; перекос в 25% дал бы у «a»…«h» примерно 4032 против 3226, то есть +// разницу в сотни раз больше статистического шума. Порог взят с большим +// запасом, чтобы тест не был флаки: он ловит систематическое смещение, а не +// проверяет качество генератора случайных чисел. +func TestRandomStringHasNoModuloBias(t *testing.T) { + const sampleSize = 200000 + + value, err := RandomString(sampleSize) + if err != nil { + t.Fatalf("RandomString: %v", err) + } + + counts := make(map[rune]int, len(charset)) + for _, r := range value { + counts[r]++ + } + + expected := float64(sampleSize) / float64(len(charset)) + + // Восемь символов, которые смещённая реализация выдавала чаще остальных. + biasedPrefix := charset[:256%len(charset)] + + var prefixTotal, restTotal float64 + for _, r := range charset { + if strings.ContainsRune(biasedPrefix, r) { + prefixTotal += float64(counts[r]) + } else { + restTotal += float64(counts[r]) + } + } + + prefixMean := prefixTotal / float64(len(biasedPrefix)) + restMean := restTotal / float64(len(charset)-len(biasedPrefix)) + + // Смещённая реализация давала prefixMean/restMean = 5/4 = 1.25. + // Допуск 5% оставляет между порогом и дефектом кратный зазор. + ratio := prefixMean / restMean + if math.Abs(ratio-1) > 0.05 { + t.Errorf( + "распределение смещено: начало алфавита %.1f, остальные %.1f, отношение %.4f", + prefixMean, restMean, ratio, + ) + } + + // Ни один символ алфавита не должен пропасть или доминировать. + for _, r := range charset { + count := float64(counts[r]) + if math.Abs(count-expected)/expected > 0.15 { + t.Errorf("символ %q встретился %d раз при ожидаемых %.0f", r, counts[r], expected) + } + } +} diff --git a/docs/02-build-layer-and-package.md b/docs/02-build-layer-and-package.md index 68652ce..513be77 100644 --- a/docs/02-build-layer-and-package.md +++ b/docs/02-build-layer-and-package.md @@ -59,16 +59,19 @@ HY2XS_TARGET_OS=debian HY2XS_TARGET_OS_VERSION=13 HY2XS_TARGET_ARCH=amd64 -GO_VERSION=1.21.13 +GO_VERSION=1.26.7 GO_LINUX_AMD64_SHA256= BUN_VERSION=1.3.13 BUN_LINUX_X64_SHA256= BUN_LINUX_X64_BASELINE_SHA256= -NODE_VERSION=20.19.0 +NODE_VERSION=24.20.0 NODE_LINUX_X64_SHA256= PNPM_VERSION=9.15.9 HYSTERIA_CHANNEL=stable + +GOVULNCHECK_VERSION=v1.7.0 +PNPM_AUDIT_LEVEL=high ``` Чего в нём **нет** и быть не должно: @@ -86,6 +89,47 @@ HYSTERIA_CHANNEL=stable выбирает `bun-linux-x64` или `bun-linux-x64-baseline` по наличию AVX2, поэтому одной контрольной суммы архитектурно недостаточно. +### Почему версии toolchain — это вопрос безопасности, а не удобства + +Go здесь не просто сборщик: им компилируется `hy2xs-admin`, и его stdlib целиком +попадает в production-бинарь. Поэтому версия выбирается по политике поддержки Go +(major поддерживается, пока не вышли две более новые), а не по тому, на чём +собиралось раньше. + +Цифры, ради которых это записано. На `GO_VERSION=1.21.13` — линия, давно вне +поддержки — `govulncheck ./...` находил **21 вызываемую уязвимость**, из них 17 в +одной только stdlib. После перехода на 1.26.7 и обновления графа зависимостей — +**ноль**. + +Node живёт только на build-хосте и в артефакт не попадает, но 20.x достигла EOL, +то есть перестала получать security-обновления, а собирает она код, который +уезжает в production. Отсюда LTS-линия 24. + +Bun обновляется отдельно от остальных: оркестратор собирается через +`bun build --compile`, то есть Bun runtime физически входит в исполняемый файл. +Смена его minor-версии — это смена рантайма внутри артефакта, и она требует +полного прохода `bun test → tsc → compile → приёмка на Debian`, а не строки в +общем патче. + +### Проверка зависимостей на уязвимости + +`tools/build/lib/security.sh` — обязательный шаг сборки между тестами админки и +записью metadata: + +| Проверка | Что покрывает | Порог | +| --- | --- | --- | +| `govulncheck ./...` | Go-граф **и stdlib**, с анализом достижимости: уязвимость считается только при наличии пути вызова из нашего кода | любая вызываемая | +| `pnpm audit --prod` | production-зависимости frontend, без анализа достижимости | `PNPM_AUDIT_LEVEL` | + +Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается +на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире. + +Аварийный выход — `ALLOW_VULNERABLE_DEPENDENCIES=true`, по той же логике, что и +`ALLOW_DIRTY_BUILD`: выпустить релиз, зная об уязвимости, можно, но это решение +человека, а не поведение по умолчанию. Результат шага уезжает в +`metadata/package.env` полем `dependency_security_gate`, так что по готовому +tarball видно, проверялся он или собран с пропущенной проверкой. + ### Проверка, а не генерация `profile.ts`, `package/config/hy2xs.env` и `packageManager` в двух `package.json` diff --git a/docs/04-admin-panel.md b/docs/04-admin-panel.md index 8449cdc..6ebb362 100644 --- a/docs/04-admin-panel.md +++ b/docs/04-admin-panel.md @@ -133,6 +133,16 @@ Hysteria обращается к машинному endpoint'у как сохраняет host, port и path — диагностика от него не страдает. Тот же проход применяется к `journal-*.log` внутри diagnostics-бандла оркестратора. +**Собственный журнал админки проходит тот же санитайз.** Раньше не проходил: он +отдавался сырым файлом через `c.File(constant.SystemLogPath)` и показывался во +вкладке без обработки. Асимметрия «чужому журналу не доверяем, своему доверяем» +ничем не обоснована — файл в обоих случаях покидает сервер и пересылается в +переписке, — и цена у неё была известна поимённо: пока bootstrap-пароль +администратора печатался в лог warning'ом, обычная кнопка выгрузки отдавала его +открытым текстом. Сам warning убран (см. ниже), но защита стоит и на выходе: +следующий неосторожный `logrus.Warnf("token=%s", …)` не превратит выгрузку +журнала в канал утечки. + ## Импорт и экспорт | Операция | Статус | @@ -165,10 +175,8 @@ API: `getConfig` и `listConfig` принимали произвольный к | Ключ | Чтение | Запись | | --- | --- | --- | -| `HYSTERIA2_TRAFFIC_TIME` | да | да | | `RESET_TRAFFIC_CRON` | да | да | -| `HYSTERIA2_CONFIG_REMARK` | да | нет | -| `HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`, `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор | +| `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор | | `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет | | любой другой | нет | нет | @@ -178,8 +186,53 @@ Denylist требует, чтобы автор каждого нового кл по умолчанию не зависит от внимательности. Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не -было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь. Право -записи `HYSTERIA2_CONFIG_REMARK` тоже убрано: панель его только отображает. +было ни одного, а фильтр на неиспользуемой двери — это по-прежнему дверь. + +### Мёртвое состояние в таблице `config` удалено + +Четыре ключа предыдущего поколения к v1 перестали чем-либо управлять и удалены +вместе со строками в базе (миграция `006_drop_dead_config_keys`): + +| Ключ | Что с ним было не так | +| --- | --- | +| `HYSTERIA2_ENABLE` | жизненным циклом Hysteria владеет systemd; единственным потребителем ключа была строка в журнале | +| `HYSTERIA2_CONFIG` | второй источник истины рядом с `/etc/hysteria/config.yaml`, причём читался **первым**: значение, попавшее в базу в обход продукта, молча становилось тем, что панель показывает и из чего генерирует ссылки | +| `HYSTERIA2_TRAFFIC_TIME` | настройка «период учёта трафика» без единого потребителя в рантайме: интервал сбора метрик задан в коде | +| `HYSTERIA2_CONFIG_REMARK` | пустая read-only строка, которую никто никогда не записывал | + +Настоящую замену получил только последний: имя профиля в клиентской ссылке +теперь выводится из **имени пира**, а при его отсутствии — из публичного хоста. +Это то различие, которое пользователю и нужно видеть в списке серверов, и оно не +требует ни одной дополнительной настройки. + +После очистки панель владеет ровно одной настройкой — `RESET_TRAFFIC_CRON`, — а +всё остальное в таблице является внутренними секретами. + +### Расписание сброса трафика: планировщик принадлежит процессу + +Смена `RESET_TRAFFIC_CRON` **не перезапускает** HTTP-сервер. + +Раньше перезапускала: обработчик вызывал `StopServer()`, точка входа крутила +`for { runServer() }` и поднимала сервис заново, чтобы новый планировщик прочитал +настройку из базы. При этом `InitCron()` на каждом вызове создавал новый +`cron.New()` и нигде не сохранял ссылку, а `cron.Stop()` не вызывался нигде. +Итог: каждая правка расписания добавляла **целый дублирующий набор джоб**, а +старое расписание сброса продолжало работать. После двух правок на процессе +висели три планировщика и три разных расписания одновременно. + +Теперь фиксированные джобы регистрируются один раз за жизнь процесса, а +расписание сброса переносится на месте по своему `EntryID`. + +Выражение проверяется **до записи в базу** тем же парсером +(`cron.ParseStandard`), которым его потом разбирает планировщик. Невалидное +значение — отказ `4xx`, база не меняется. Раньше строка сохранялась, API отвечал +успехом, а сброс трафика молча исчезал до следующего чтения журнала. + +Пустое значение легально и означает «автоматический сброс выключен». + +Сервис завершается штатно по `SIGTERM`: сначала останавливается планировщик и +дожидаются запущенные джобы, затем закрывается SQLite. Обратный порядок означал +бы работу джоб с уже закрытым соединением при каждом `systemctl restart`. Ключи оркестратора отклоняются отдельным сообщением, называющим владельца, — «этим значением владеет оркестратор» это другой ответ, чем «такого ключа нет», @@ -191,6 +244,23 @@ Denylist требует, чтобы автор каждого нового кл секреты пиров на диске, накапливающиеся с каждым нажатием кнопки. Каталога `export/` больше не существует. +### Партия настроек применяется целиком или не применяется вовсе + +`POST /api/config/updateConfigs` выполняется в три прохода: + +1. проверка партии целиком — права на ключ, дубликаты ключей, значения; +2. одна транзакция базы; +3. применение к рантайму. + +Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал. +Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на +втором — оператор получал отказ на запрос, который систему уже изменил. + +Тест на этот случай существовал, но ставил запрещённый ключ **первым** и не +смотрел в базу — поймать частичное применение он был неспособен по построению. +Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы +проверяется явно: `TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden`. + ### Экспорт пиров: два режима, а не флаг | Кнопка | Запрос | Что внутри | @@ -380,6 +450,72 @@ upstream выберет для нового секрета. Список мар - `HY2XS_ADMIN_DATA_DIR` - `HY2XS_ADMIN_LOG_DIR` +## Учётные данные и аутентификация + +### Bootstrap-учётные данные приходят от оркестратора + +Первая учётная запись администратора создаётся из `HY2XS_ADMIN_INITIAL_PASSWORD`, +пир установщика — из `HY2XS_ADMIN_CON_PASS`. Оба значения задаёт оркестратор +через `/etc/hy2xs/hy2xs.env`, а копию кладёт в +`/etc/hy2xs/bootstrap-admin.secret`. + +Если переменной нет, а создавать учётную запись нужно, админка **отказывает в +старте** с сообщением, называющим причину и способ починки. + +Раньше она в этом случае придумывала пароль сама и печатала его двумя +`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в +файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо +утечки, у такого пароля была вторая проблема: его не знал никто, кроме журнала. +Попадание в эту ветку означает не «нужно что-то придумать», а повреждённый +контракт запуска, и реакция на него должна быть громкой. + +Для пира установщика цена ошибки ещё конкретнее: его секрет продублирован в +`bootstrap-admin.secret`, откуда его читает проверка machine-auth в smoke +оркестратора. Придуманный админкой секрет разошёлся бы с файлом, и проверка +подключения провалилась бы на корректном во всём остальном сервере. + +Порядок проверок при этом такой: сначала выясняется, нужно ли вообще создавать +запись, и только потом требуется переменная. Перезапуск уже установленного +сервиса без неё работает штатно. + +### Пир установщика защищён во всех путях записи + +`bootstrap-admin-peer` нельзя переименовать, переподписать или занять его имя +чужим пиром — ни импортом, ни через обычные формы панели. Раньше проверка стояла +только в импорте, то есть ровно то, ради чего она существует, делалось через +интерфейс. + +Удаление и отключение при этом **разрешены**: после установки это обычный +действующий доступ, секрет которого лежит ещё и в файле на диске, и оператор +обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не +создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке. + +### Токены и пароли + +Токены выписываются и проверяются `golang-jwt/jwt/v5`. Переход с v3 — +не косметика: у `github.com/golang-jwt/jwt` v3.2.2 есть GO-2025-3553, у которой +**нет исправленной версии в ветке v3** (`Fixed in: N/A`), а уязвимый код +достигается из разбора токена, то есть с неаутентифицированного запроса. +Обновлять было нечего — лечится только сменой мажорной ветки. + +Заодно закрыт тихий недостаток прежней реализации: `keyfunc` возвращал ключ, +**не проверяя алгоритм подписи**, то есть набор допустимых алгоритмов +фактически задавал сам токен. Сейчас разбор ограничен `jwt.WithValidMethods`, +проверяются `issuer` и обязательное наличие срока жизни, а пустой `JWT_SECRET` +считается повреждённым состоянием, а не ключом нулевой длины. + +Пароли администратора хранятся ровно в одном формате — bcrypt. Ветка сравнения +с несолёным SHA-224 (формат предыдущего поколения) удалена: в v1 такой хеш не +может появиться — миграции таблицы `account` удалены, установка возможна только +на чистый хост, а конфигурация 0.x отклоняется по `HY2XS_CONFIG_SCHEMA_VERSION`. +Compatibility-ветка пережила слой совместимости, ради которого существовала, и +осталась запасным путём проверки пароля слабым алгоритмом в обработчике логина. + +Все секреты продукта генерируются одним примитивом `util.RandomString` с +отбраковкой (rejection sampling): прежняя реализация брала остаток байта от +деления на длину алфавита, из-за чего первые восемь символов алфавита выпадали +примерно на четверть чаще остальных. + ## Инварианты Схема считается корректной, если: @@ -394,3 +530,10 @@ upstream выберет для нового секрета. Список мар 8. экспорт конфига сохраняет неизвестные upstream-поля 9. экспорт конфига не содержит секретов 10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую +11. планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер +12. значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет +13. партия настроек применяется целиком или не применяется вовсе +14. в таблице `config` нет ключей без потребителя +15. bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой +16. любой журнал, покидающий сервер, проходит санитайз +17. пароль администратора хранится ровно в одном формате — bcrypt diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 70eca76..b99f51b 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -293,7 +293,81 @@ wildcard-маршрутом фронтенда или дублирующая р - отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт, что обработчик не падает, это и доказывает); - ключи оркестратора отклоняются с указанием владельца, а не общим «нет такого - ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`. + ключа»: оператор должен быть отправлен к `hy2xs-orchestrator reconfigure`; +- удалённые ключи (`HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`, + `HYSTERIA2_TRAFFIC_TIME`, `HYSTERIA2_CONFIG_REMARK`) отклоняются как + неизвестные — проверка идёт по строковым литералам, потому что соответствующих + констант в коде уже нет и появиться они не должны. + +Атомарность партии проверяется на **настоящей** SQLite: без базы утверждение +«партия не применилась частично» бессмысленно, поскольку предметом утверждения +является именно состояние базы. + +- разрешённый ключ первым, запрещённый вторым → запрос отклонён, значение + первого ключа в базе **не изменилось**; +- невалидное cron-выражение → отказ, значение в базе не изменилось; +- один ключ дважды в партии → отказ (какое из двух значений считать намерением + оператора, определить нельзя); +- корректная партия → значение сохранено, расписание применено к планировщику, + число его записей не выросло. + +Порядок в первом тесте принципиален. Предыдущая версия ставила запрещённый ключ +**первым** и до второго элемента не доходила, поэтому проходила и на реализации, +которая проверяла и записывала настройки в одном цикле. + +## A9b. Планировщик (unit) + +`apps/service/cron_scheduler_test.go` — планировщик как собственность процесса: + +- четыре последовательные смены расписания **не увеличивают** число записей + планировщика (главная регрессия: раньше каждая смена добавляла целый + дублирующий набор джоб, а старое расписание продолжало работать); +- пустое выражение снимает джобу сброса, непустое возвращает её — без + перезапуска процесса; +- невалидное выражение не меняет планировщик и не снимает действующую джобу; +- набор валидных и невалидных выражений проверяется тем же парсером, что и + runtime: то, что `cron` умеет, обязано приниматься, остальное — отклоняться; +- невалидное значение в базе **не роняет старт**: на панели висит + `/internal/hysteria/auth`, и отказ старта из-за строки расписания положил бы + подключения пользователей. Фиксированные джобы поднимаются, сброс отключён, + в журнале ERROR, и настройка чинится через API без перезапуска; +- второй `InitCron` поверх работающего отклоняется; +- `StopCron` идемпотентен и оставляет планировщик пустым. + +## A9c. Токены и пароли (unit) + +`apps/service/jwt_test.go`: + +- round-trip: claims, включая `token_version`, переживают выписку и разбор; +- токен, подписанный **другим** HMAC-алгоритмом тем же ключом, отклоняется + (прежний `keyfunc` не смотрел на `token.Method` вовсе); +- токен без `exp` отклоняется, истёкший отклоняется отдельным сообщением; +- токен с чужим `issuer` отклоняется даже при совпадении ключа; +- пустой `JWT_SECRET` — отказ и на выписку, и на разбор, а не подпись ключом + нулевой длины. + +`apps/util/encrypt_test.go`: + +- `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются; +- вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**; +- любая не-bcrypt строка в поле хеша отклоняется. + +`apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов +частоты первых восьми символов алфавита не отличаются от остальных более чем на +5%. Прежняя реализация давала здесь отношение 1.25. + +## A9d. Пир установщика (unit) + +`apps/service/peer_bootstrap_guard_test.go` — защита действует во всех путях +записи, а не только в импорте: + +- смена секрета и переименование `bootstrap-admin-peer` отклоняются, состояние + в базе не меняется; +- переименование обычного пира в зарезервированное имя отклоняется; +- создание пира с зарезервированным именем отклоняется; +- отключение и изменение квоты **разрешены**; +- удаление **разрешено**: это осознанное действие оператора, и расхождения + между базой и `bootstrap-admin.secret` оно не создаёт. ## A10. Импорт пиров (unit) @@ -349,6 +423,43 @@ metadata пакета и версией, которую сообщает соб `hysteria-linux-amd64-avx`, форма `sha256:`, верхний регистр, противоречивые записи, отсутствие нужной строки. +## A11. Проверка зависимостей на уязвимости (build) + +`tools/build/lib/security.sh` — обязательный шаг между тестами админки и записью +metadata. Подробности в [docs/02](02-build-layer-and-package.md); здесь важно +поведение при отказе: + +| Код `govulncheck` | Трактовка | +| --- | --- | +| `0` | чисто | +| `3` | найдены **вызываемые** уязвимости → сборка падает | +| иное | отказ самого инструмента → сборка падает отдельным сообщением | + +Последняя строка существенна: ненулевой код неизвестной природы нельзя +трактовать как «уязвимостей нет». По той же причине недоступность реестра npm +для `pnpm audit` — это отказ проверки, а не её отрицательный результат. + +## A12. Приёмка проверяет код, а не упоминания + +Два контракта приёмки на снимке до этого патча **гарантированно роняли сборку на +корректном коде**, и оба — по одной причине: они искали подстроку там, где +подстрока обязана присутствовать. + +| Проверка | Что ловила на самом деле | +| --- | --- | +| `verify_api_namespace_contract` | `grep -rlF '/hui'` возвращал `apps/router/router_test.go` (регрессионный тест, который ПЕРЕЧИСЛЯЕТ legacy-префикс, чтобы доказать его отсутствие) и сам `versions.sh`, где эта строка стоит в тексте проверки | +| «peer import не выходит за транзакцию» | `source.slice(start)` брал файл от начала `applyPeerImportEntry` **и до конца**, захватывая `ExistPeerName` и `UpdatePeerLastConnectionAt` — обычные операции вне импорта, которым глобальное соединение положено | + +Первая падала на шаге versions contract — шестым из четырнадцати, до резолва +Hysteria. Вторая не была замечена только потому, что сборка до неё не доходила. + +Отсюда правило и помощники `code_without_comments` / `code_mentions_in` в +`acceptance.sh`: проверка смотрит на код, а комментарий, объясняющий, почему +чего-то больше нет, обязан называть это по имени и не должен ломать сборку. +Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а +`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера — +и существование этого теста само проверяется контрактом. + ## B. Target install tests ### На чистом Debian 13 проверяем @@ -384,9 +495,13 @@ metadata пакета и версией, которую сообщает соб 17. `nft -c -f /etc/nftables.conf` проходит после apply 18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin` 19. остановка/рестарт UI не останавливает `hysteria-server` -20. traffic accounting/kick ориентируются на systemd status, а не на SQLite `HYSTERIA2_ENABLE` +20. traffic accounting/kick ориентируются на systemd status; ключа `HYSTERIA2_ENABLE` в базе больше нет 21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin` 22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать +23. смена расписания сброса трафика применяется **без** перезапуска `hy2xs-admin`, и число джоб планировщика не растёт +24. невалидное cron-выражение отклоняется API, а значение в базе не меняется +25. `systemctl restart hy2xs-admin` завершает сервис штатно: планировщик остановлен до закрытия SQLite, в журнале нет `database is closed` +26. `govulncheck ./...` на графе релиза не находит вызываемых уязвимостей ## C1. Семантический smoke конфига diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index 6428dac..9734522 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -280,6 +280,66 @@ Update the DNS A record before using this server. `HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` используются только как bootstrap-данные при первичной установке. Для ротации существующих credentials нужен отдельный flow на уровне account-management. +### `hy2xs-admin` не стартует: «HY2XS_ADMIN_INITIAL_PASSWORD не задан» + +Означает, что учётной записи администратора в базе нет, а переменной, из которой +её положено создать, — тоже. + +Придумывать пароль самостоятельно админка не будет: такой пароль не знал бы +никто, кроме журнала, а раньше именно он туда и попадал открытым текстом. +Сообщение говорит о повреждённом контракте запуска. + +Что проверять: + +```bash +systemctl cat hy2xs-admin | grep EnvironmentFile +grep -c '^HY2XS_ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/hy2xs.env +grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret +``` + +Починка — `hy2xs-orchestrator repair --allow-partial-state`: значения принадлежат +оркестратору, он же приводит `hy2xs.env` и `bootstrap-admin.secret` в +согласованное состояние. + +Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика. +Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка +machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же +проверка подключения провалилась бы. + +### Забыт пароль администратора + +```bash +systemctl stop hy2xs-admin +set -a; . /etc/hy2xs/hy2xs.env; set +a +"$HY2XS_INSTALL_DIR/hy2xs-admin" reset-admin +systemctl start hy2xs-admin +``` + +Runtime env подключается намеренно: из него берутся пути к базе и журналу +(`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) — те же, с которыми работает юнит. + +Команда печатает новые логин и пароль в консоль и требует смены пароля при +первом входе. Работает поверх существующей установки; на машине без базы она +осмысленно откажет — это инструмент восстановления, а не установки. + +### Автоматический сброс трафика не срабатывает + +Проверьте сохранённое расписание: + +```bash +journalctl -u hy2xs-admin | grep RESET_TRAFFIC_CRON +``` + +Невалидное выражение теперь отклоняется API до записи в базу, поэтому попасть в +это состояние можно только правкой базы в обход продукта. Планировщик в таком +случае поднимается без джобы сброса и пишет ERROR — старт сервиса при этом не +прерывается намеренно: на панели висит `/internal/hysteria/auth`, и её отказ +положил бы подключения пользователей. + +Починка — сохранить корректное значение в разделе настроек панели; оно +применяется сразу, без перезапуска сервиса. Пустое значение — легальное и +означает «автоматический сброс выключен». + ## Правила эксплуатации 1. Не править сервер как будто на нём есть builder. diff --git a/tools/build/build.sh b/tools/build/build.sh index 2ae3bf1..afad167 100755 --- a/tools/build/build.sh +++ b/tools/build/build.sh @@ -19,6 +19,8 @@ load_versions_contract . "$BUILD_DIR/lib/hysteria.sh" # shellcheck source=tools/build/lib/acceptance.sh . "$BUILD_DIR/lib/acceptance.sh" +# shellcheck source=tools/build/lib/security.sh +. "$BUILD_DIR/lib/security.sh" # shellcheck source=tools/build/lib/package.sh . "$BUILD_DIR/lib/package.sh" @@ -58,6 +60,13 @@ main() { log_step "Checking HY2XS admin contracts" run_admin_tests + # Проверка уязвимостей идёт после bundle_ui по двум причинам: govulncheck + # анализирует пакеты, а `go:embed all:dist` не собирается без ассетов; pnpm + # audit требует установленных зависимостей frontend, которые кладёт + # `pnpm install --frozen-lockfile` внутри bundle_ui. + log_step "Checking dependency vulnerabilities" + run_dependency_security_gate + log_step "Writing metadata" write_metadata "$PACKAGE_VERSION" "$BUILD_ID" diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index bdf5c1c..d0d6ba0 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -1,6 +1,39 @@ #!/usr/bin/env bash set -euo pipefail +# code_without_comments отдаёт содержимое файла без строк-комментариев. +# +# Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий, +# почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен, +# — и проверка, ищущая просто подстроку, начинает падать ровно на той +# документации, которая описывает выполненную ею же работу. +# +# Урок в этом файле уже дважды оплачен: скан versions contract ловил сам себя на +# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ +# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы +# третьего раза не было. +code_without_comments() { + grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true +} + +# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ. +code_mentions_in() { + local needle="$1" + shift + + local file hits="" + while IFS= read -r file; do + [ -n "$file" ] || continue + if code_without_comments "$file" | grep -qF -- "$needle"; then + hits="${hits}${hits:+ }${file}" + fi + done </dev/null || true) +EOF + + printf '%s' "$hits" +} + run_fix20_acceptance_subset() { local package_dir="$1" [ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir" @@ -371,6 +404,165 @@ run_clean_install_acceptance() { run_secret_channel_acceptance run_atomic_import_acceptance run_legacy_account_acceptance + run_scheduler_lifecycle_acceptance + run_dead_config_acceptance + run_dependency_hygiene_acceptance "$package_dir" +} + +# Планировщик принадлежит процессу и не перезапускает HTTP-сервер. +run_scheduler_lifecycle_acceptance() { + log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server" + + [ -f apps/service/cron_scheduler.go ] \ + || fail "acceptance: модуль планировщика отсутствует" + [ ! -f apps/middleware/cron.go ] \ + || fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера" + + # Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер. + # Именно этот путь плодил планировщики — StopServer возвращал управление в + # `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку. + ! code_without_comments apps/controller/config.go | grep -q 'StopServer' \ + || fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы" + grep -q 'RescheduleResetTraffic' apps/controller/config.go \ + || fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic" + + # Точка входа не должна крутить сервер в цикле: цикл существовал только ради + # внутрипроцессного перезапуска. + ! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \ + || fail "acceptance: цикл перезапуска сервера вернулся в точку входа" + + # Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы. + grep -q 'func StopCron' apps/service/cron_scheduler.go \ + || fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite" + grep -q 'defer service.StopCron()' apps/cmd/server.go \ + || fail "acceptance: планировщик не останавливается при завершении сервиса" + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8"); + const release = source.indexOf("defer releaseResource()"); + const stopCron = source.indexOf("defer service.StopCron()"); + if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron"); + // defer выполняются в обратном порядке регистрации: releaseResource должен + // быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron. + if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика"); + ' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы" + + # Сигналы: SIGTERM от systemd обязан приводить к штатному завершению. + grep -q 'signal.NotifyContext' apps/cmd/server.go \ + || fail "acceptance: сервис не обрабатывает сигнал завершения от systemd" + grep -q 'syscall.SIGTERM' apps/cmd/server.go \ + || fail "acceptance: SIGTERM не входит в набор сигналов завершения" + + log_step "Acceptance: cron expression is validated by the runtime parser before it is stored" + grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \ + || fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик" + grep -q 'ValidateConfigValue' apps/controller/config.go \ + || fail "acceptance: значения настроек должны проверяться до записи в базу" + + log_step "Acceptance: config batch is validated fully, then applied in one transaction" + grep -q 'func WithConfigTx' apps/dao/config.go \ + || fail "acceptance: у записи настроек нет транзакционной границы" + grep -q 'sqliteDB.Transaction' apps/dao/config.go \ + || fail "acceptance: WithConfigTx должен открывать настоящую транзакцию" + grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \ + || fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу" + # Проверка обязана завершиться ДО первой записи. + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8"); + const validate = source.indexOf("service.ValidateConfigValue("); + const apply = source.indexOf("service.UpdateConfigs(updates)"); + if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии"); + if (validate > apply) throw new Error("партия применяется раньше, чем проверяется"); + ' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи" +} + +# Мёртвые ключи таблицы `config` не должны вернуться. +run_dead_config_acceptance() { + log_step "Acceptance: dead config keys stay removed" + + # Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их + # отсутствие, а комментарии объясняют, почему ключей больше нет. И то и + # другое обязано называть их по имени. + local dead_key hits + for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do + hits="$(code_mentions_in "$dead_key" \ + apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)" + [ -z "$hits" ] \ + || fail "acceptance: удалённый ключ $dead_key вернулся в: $hits" + done + + grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \ + || fail "acceptance: миграция удаления мёртвых ключей пропала" + + # Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite» + # была вторым источником истины и читалась ПЕРВОЙ. + ! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \ + || fail "acceptance: чтение серверного конфига снова обращается к таблице config" + ! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \ + || fail "acceptance: выгрузка серверного конфига снова обращается к таблице config" + + log_step "Acceptance: share URI remark is derived, not stored as dead state" + grep -q 'func shareRemark' apps/service/hysteria2_api.go \ + || fail "acceptance: имя профиля в share URI должно выводиться детерминированно" +} + +# Гигиена зависимостей и bootstrap-учётных данных. +run_dependency_hygiene_acceptance() { + local package_dir="$1" + + log_step "Acceptance: bootstrap credentials are never invented or logged" + + # Ключевой канал утечки: сгенерированный пароль печатался в + # /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки. + ! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \ + || fail "acceptance: bootstrap-пароль снова пишется в журнал" + grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \ + || fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта" + grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \ + || fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира" + + log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one" + grep -q 'func ExportAdminLog' apps/service/journal.go \ + || fail "acceptance: журнал админки выгружается без санитайза" + ! code_without_comments apps/controller/log.go | grep -q 'c.File(' \ + || fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза" + + log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm" + local jwt_v3_hits + jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \ + apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)" + [ -z "$jwt_v3_hits" ] \ + || fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits" + grep -q 'golang-jwt/jwt/v5' apps/go.mod \ + || fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей" + grep -q 'jwt.WithValidMethods' apps/service/jwt.go \ + || fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи" + + log_step "Acceptance: password storage has exactly one format" + ! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \ + || fail "acceptance: вернулась проверка пароля по несолёному SHA-224" + grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \ + || fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt" + + log_step "Acceptance: the release is built with a dependency vulnerability gate" + [ -f tools/build/lib/security.sh ] \ + || fail "acceptance: модуль проверки зависимостей отсутствует" + grep -q 'run_dependency_security_gate' tools/build/build.sh \ + || fail "acceptance: сборка не запускает проверку зависимостей" + grep -q '^GOVULNCHECK_VERSION=' versions.env \ + || fail "acceptance: версия govulncheck должна быть частью контракта сборки" + + # По готовому tarball должно быть видно, проверялся он или собран с + # пропущенной проверкой — ровно как hysteria_compat_gate. + grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \ + || fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей" + + log_step "Acceptance: the Go toolchain is on a supported line" + # Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь. + # На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib. + local go_minor + go_minor="$(grep -E '^GO_VERSION=' versions.env | head -n1 | cut -d= -f2 | cut -d. -f2)" + [ "$go_minor" -ge 26 ] \ + || fail "acceptance: GO_VERSION 1.${go_minor} вне поддерживаемой линии Go; production-бинарь унаследует её stdlib" } # PHASE 1 принадлежит оркестратору целиком. @@ -537,11 +729,23 @@ run_atomic_import_acceptance() { grep -q 'sqliteDB.Transaction' apps/dao/peer.go \ || fail "acceptance: WithPeerTx must open a real database transaction" # Применение обязано идти по tx, а не по глобальному соединению. + # + # Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл + # от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все + # функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и + # UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта, + # которым глобальное соединение положено. Проверка гарантированно падала на + # корректном коде и не была замечена только потому, что сборка до неё не + # доходила: раньше неё падал versions contract на собственном скане '/hui'. + # + # Границей тела функции служит следующее объявление верхнего уровня. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8"); const start = source.indexOf("func applyPeerImportEntry"); if (start < 0) throw new Error("applyPeerImportEntry is missing"); - const body = source.slice(start); + const rest = source.slice(start + 1); + const end = rest.indexOf("\nfunc "); + const body = end < 0 ? rest : rest.slice(0, end); for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) { if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call); } diff --git a/tools/build/lib/package.sh b/tools/build/lib/package.sh index 8ded74d..3143d45 100644 --- a/tools/build/lib/package.sh +++ b/tools/build/lib/package.sh @@ -158,6 +158,8 @@ write_metadata() { printf 'bun_compile_target=%s\n' "${BUN_COMPILE_TARGET:-unknown}" printf 'node_version=%s\n' "$($NODE_BIN --version)" printf 'pnpm_version=%s\n' "$($PNPM_BIN --version)" + printf 'govulncheck_version=%s\n' "${GOVULNCHECK_VERSION:-unknown}" + printf 'dependency_security_gate=%s\n' "${DEPENDENCY_SECURITY_GATE:-unknown}" printf 'hysteria_source=official-upstream\n' printf 'hysteria_target=linux-amd64\n' printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION" diff --git a/tools/build/lib/security.sh b/tools/build/lib/security.sh new file mode 100644 index 0000000..aca3acd --- /dev/null +++ b/tools/build/lib/security.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Проверка графа зависимостей на известные уязвимости. +# +# Зачем отдельный шаг сборки, а не разовая проверка «когда вспомним». +# +# Версии toolchain и библиотек фиксируются в versions.env, go.sum и lock-файлах +# — то есть намеренно НЕ движутся. Это правильно для воспроизводимости и ровно +# поэтому опасно для безопасности: зафиксированный граф не стареет только на +# бумаге, а advisory по нему выходят и после фиксации. Единственный момент, +# когда расхождение между «что мы собираем» и «что про это известно» можно +# поймать дёшево, — сама сборка релиза. +# +# История, ради которой шаг появился: на Go 1.21.13 и графе поколения 0.x +# govulncheck находил 21 ВЫЗЫВАЕМУЮ уязвимость, включая GO-2025-3553 в +# github.com/golang-jwt/jwt v3 — с пометкой `Fixed in: N/A` и путём вызова из +# разбора токена, то есть достижимую с неаутентифицированного запроса. Ни один +# из существовавших шагов сборки этого показать не мог. +# +# Проверяются РАЗНЫЕ вещи в двух экосистемах: +# +# govulncheck — анализ достижимости: уязвимость считается только если до неё +# есть путь вызова из нашего кода. Покрывает и stdlib, то есть +# ровно то, что версия Go привносит в production-бинарь; +# pnpm audit — граф npm без анализа достижимости, поэтому ограничен +# production-зависимостями и порогом PNPM_AUDIT_LEVEL: +# devDependencies в артефакт не попадают. + +# Аварийный выход существует по той же причине, что и ALLOW_DIRTY_BUILD: новое +# advisory может выйти в неудачный момент, и решение «выпускаем, зная о нём» +# должно приниматься человеком ЯВНО, а не пропуском шага по умолчанию. +ALLOW_VULNERABLE_DEPENDENCIES="${ALLOW_VULNERABLE_DEPENDENCIES:-false}" + +security_gate_failed() { + local scanner="$1" + local details="$2" + + if [ "$ALLOW_VULNERABLE_DEPENDENCIES" = "true" ]; then + log_info "WARNING: $scanner нашёл уязвимости, но сборка продолжена по ALLOW_VULNERABLE_DEPENDENCIES=true" + log_info "$details" + DEPENDENCY_SECURITY_GATE="accepted-risk" + export DEPENDENCY_SECURITY_GATE + return 0 + fi + + fail "$scanner: найдены уязвимости в зависимостях. Обновите граф или, осознанно принимая риск, выставьте ALLOW_VULNERABLE_DEPENDENCIES=true. +$details" +} + +# Анализ Go-графа, включая stdlib выбранной версии Go. +run_go_vulnerability_gate() { + local ui_src="${UI_SRC:-apps}" + local report status + + log_step "Security: govulncheck ${GOVULNCHECK_VERSION} (Go ${GO_VERSION})" + + # GOTOOLCHAIN=local обязателен: без него go может молча скачать другую + # версию toolchain, и проверялась бы не та stdlib, которая попадёт в бинарь. + set +e + report="$(cd "$ui_src" && GOTOOLCHAIN=local "$GO_BIN" run \ + "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" ./... 2>&1)" + status=$? + set -e + + # 0 — чисто; 3 — найдены вызываемые уязвимости; остальное — отказ самого + # инструмента, и его нельзя трактовать как «уязвимостей нет». + case "$status" in + 0) + log_info "govulncheck: вызываемых уязвимостей не найдено" + ;; + 3) + security_gate_failed "govulncheck" "$report" + ;; + *) + fail "govulncheck завершился с кодом $status (это отказ инструмента, а не результат проверки): +$report" + ;; + esac +} + +# Анализ production-зависимостей frontend. +run_frontend_vulnerability_gate() { + local ui_src="${UI_SRC:-apps}" + local report status + + log_step "Security: pnpm audit --prod (порог ${PNPM_AUDIT_LEVEL})" + + set +e + report="$(cd "$ui_src/frontend" && "$PNPM_BIN" audit --prod --audit-level "$PNPM_AUDIT_LEVEL" 2>&1)" + status=$? + set -e + + if [ "$status" -eq 0 ]; then + log_info "pnpm audit: уязвимостей уровня ${PNPM_AUDIT_LEVEL} и выше не найдено" + return 0 + fi + + # pnpm audit ходит в реестр npm. Недоступность реестра — это отказ проверки, + # а не её отрицательный результат, и молча пропускать его нельзя. + if printf '%s' "$report" | grep -qiE 'ERR_PNPM_AUDIT_ENDPOINT|ENOTFOUND|ECONNREFUSED|network|getaddrinfo'; then + fail "pnpm audit не смог обратиться к реестру npm — проверка не выполнена: +$report" + fi + + security_gate_failed "pnpm audit" "$report" +} + +# Результат шага уезжает в metadata/package.env: по готовому tarball должно +# быть видно, проверялся он или собран с пропущенной проверкой. Ровно так же +# устроен hysteria_compat_gate. +run_dependency_security_gate() { + if [ "${SKIP_SECURITY_SCAN:-false}" = "true" ]; then + log_info "WARNING: проверка зависимостей на уязвимости пропущена (SKIP_SECURITY_SCAN=true)" + DEPENDENCY_SECURITY_GATE="skipped" + export DEPENDENCY_SECURITY_GATE + return 0 + fi + + [ -n "${GOVULNCHECK_VERSION:-}" ] \ + || fail "security: GOVULNCHECK_VERSION не задан; load_versions_contract должен выполниться первым" + [ -n "${PNPM_AUDIT_LEVEL:-}" ] \ + || fail "security: PNPM_AUDIT_LEVEL не задан; load_versions_contract должен выполниться первым" + + DEPENDENCY_SECURITY_GATE="true" + export DEPENDENCY_SECURITY_GATE + + run_go_vulnerability_gate + run_frontend_vulnerability_gate +} diff --git a/tools/build/lib/versions.sh b/tools/build/lib/versions.sh index aa03a8d..7892a9e 100644 --- a/tools/build/lib/versions.sh +++ b/tools/build/lib/versions.sh @@ -34,6 +34,8 @@ REQUIRED_CONTRACT_VARS=( NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL + GOVULNCHECK_VERSION + PNPM_AUDIT_LEVEL ) validate_sha256_value() { @@ -99,6 +101,7 @@ load_versions_contract() { export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256 + export GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL } # Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без @@ -217,17 +220,31 @@ verify_api_namespace_contract() { # Историческое имя допустимо только в docs/14-legacy-cleanup.md и в # legacy-маркерах clean-host: там это имя чужого артефакта, а не наше. # - # Список каталогов покрывает КАЖДЫЙ слой, который может собрать URL: раньше - # в нём не было apps/middleware, apps/cmd, apps/dao и apps/util, то есть - # ровно тех мест, где живут HTTP-логгер, точка входа сервера и helpers. + # Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет. + # Файлы, задача которых — доказать отсутствие legacy-пространства, из скана + # исключены: они обязаны называть его по имени, иначе проверять нечего. + # + # Регрессия приёмки. Раньше исключений не было, а в список каталогов входили + # tools/build и orchestrator/test. Контракт ловил сам себя: grep возвращал + # apps/router/router_test.go (регрессионный тест на отсутствие маршрута) и + # этот самый файл, где строка '/hui' стоит в тексте проверки. Сборка падала + # на шаге versions contract — то есть добавление теста, закрепляющего + # очистку, гарантированно ломало сборку. + # + # Гарантия при этом не ослабла, а переехала на слой, где она сильнее: скан + # исходников доказывает отсутствие СТРОКИ, а TestRouterHasNoLegacyNamespace + # проверяет ТАБЛИЦУ МАРШРУТОВ собранного роутера. Поэтому существование + # этого теста тоже входит в контракт и проверяется ниже. local legacy_hits - legacy_hits="$(grep -rlF '/hui' \ + legacy_hits="$(grep -rlF --exclude='*_test.go' '/hui' \ apps/model apps/router apps/controller apps/service apps/middleware \ apps/cmd apps/dao apps/util apps/frontend/src \ - orchestrator/src orchestrator/test package/templates \ - tools/test tools/build 2>/dev/null || true)" + orchestrator/src package/templates tools/test 2>/dev/null || true)" [ -z "$legacy_hits" ] \ || fail "versions contract: legacy /hui namespace came back in: $legacy_hits" + + grep -q 'func TestRouterHasNoLegacyNamespace' apps/router/router_test.go \ + || fail "versions contract: the legacy-namespace route test disappeared from apps/router/router_test.go" } verify_go_toolchain_contract() { diff --git a/versions.env b/versions.env index f571b85..e6d6efa 100644 --- a/versions.env +++ b/versions.env @@ -40,21 +40,56 @@ HY2XS_TARGET_ARCH=amd64 # ------------------------------------------------------------ toolchain --- -GO_VERSION=1.21.13 -GO_LINUX_AMD64_SHA256=502fc16d5910562461e6a6631fb6377de2322aad7304bf2bcd23500ba9dab4a7 +# Go входит в production-артефакт, а не только в сборку: им компилируется +# hy2xs-admin, и его stdlib целиком попадает в бинарь. Поэтому версия здесь +# выбирается по политике поддержки Go (major поддерживается, пока не вышли две +# более новые), а не по удобству. +# +# Было 1.21.13 — линия вне поддержки. На ней `govulncheck ./...` находил 21 +# ВЫЗЫВАЕМУЮ уязвимость, из них 17 в stdlib. На 1.26.7 — ноль. +GO_VERSION=1.26.7 +GO_LINUX_AMD64_SHA256=ffb5f8de10c62550dfddab66b36b57030721e0a44a3218e9e1181d7b59f121ca # Bun выбирает артефакт по наличию AVX2, поэтому одной контрольной суммы # архитектурно недостаточно: обе должны быть зафиксированы заранее. +# +# 1.3.13 оставлена сознательно, хотя 1.4 уже вышел. Оркестратор собирается +# через `bun build --compile`, то есть Bun runtime физически входит в +# исполняемый файл, а 1.4 — крупное внутреннее обновление рантайма. Такой +# переход стоит отдельного контролируемого прохода по всей матрице +# `bun test → tsc → compile → Debian acceptance`, а не строки в общем патче. BUN_VERSION=1.3.13 BUN_LINUX_X64_SHA256=79c0771fa8b92c33aae41e15a0e0d307ea99d0e2f00317c71c6c53237a78e25a BUN_LINUX_X64_BASELINE_SHA256=9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750 -NODE_VERSION=20.19.0 -NODE_LINUX_X64_SHA256=b4e336584d62abefad31baecff7af167268be9bb7dd11f1297112e6eed3ca0d5 +# Node живёт только на build-хосте (сборка frontend-ассетов) и в runtime-артефакт +# не попадает. Но 20.x достигла EOL, то есть перестала получать в том числе +# security-обновления, а собирает она код, который уезжает в production. +# 24.20.0 — текущая LTS-линия. +NODE_VERSION=24.20.0 +NODE_LINUX_X64_SHA256=2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2 # pnpm ставится через corepack, отдельного архива с контрольной суммой нет. +# +# Линия 9 оставлена вместе с apps/frontend/pnpm-lock.yaml: переход на 10 меняет +# формат lock-файла, то есть затрагивает воспроизводимость сборки frontend, и +# относится к обновлению его зависимостей, а не toolchain. PNPM_VERSION=9.15.9 +# ------------------------------------------------ проверка зависимостей --- + +# Версия govulncheck пиньтся здесь, а не берётся @latest: инструмент, которым +# проверяется релиз, сам обязан быть частью контракта сборки. Иначе один и тот +# же коммит проверяется разными анализаторами в разные дни. +# +# База уязвимостей при этом ЖИВАЯ и подтягивается на каждом запуске — это и +# нужно: пин версии инструмента не должен превращаться в пин знаний о мире. +GOVULNCHECK_VERSION=v1.7.0 + +# Порог pnpm audit для production-зависимостей frontend. +# Значения: low | moderate | high | critical. +PNPM_AUDIT_LEVEL=high + # ------------------------------------------------ политика выбора Hysteria --- # stable — резолвить последний стабильный upstream-релиз на build-машине;