fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.
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 пакета.
This commit is contained in:
+191
-1
@@ -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`
|
- **Каждая чистая установка падала сразу после `apt-get`.** Внутри `install`
|
||||||
`preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста.
|
`preflight()` вызывался дважды, и оба раза проверял контракт чистого хоста.
|
||||||
|
|||||||
@@ -920,9 +920,10 @@ export GITHUB_TOKEN=<token>
|
|||||||
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander;
|
||||||
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
|
5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта;
|
||||||
6. прогоняет `go vet` и `go test` для HY2XS admin;
|
6. прогоняет `go vet` и `go test` для HY2XS admin;
|
||||||
7. формирует архив и прогоняет acceptance‑проверки.
|
7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit --prod`);
|
||||||
|
8. формирует архив и прогоняет acceptance‑проверки.
|
||||||
|
|
||||||
Любой сбой на шагах 1–6 останавливает сборку до создания пакета.
|
Любой сбой на шагах 1–7 останавливает сборку до создания пакета.
|
||||||
|
|
||||||
Переменные, управляющие выбором версии Hysteria:
|
Переменные, управляющие выбором версии Hysteria:
|
||||||
|
|
||||||
@@ -934,6 +935,19 @@ export GITHUB_TOKEN=<token>
|
|||||||
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass |
|
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass |
|
||||||
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
|
| `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: ссылка
|
Полный E2E с реальным клиентом Hysteria запускается отдельно (нужен Go: ссылка
|
||||||
берётся из production‑генератора, а не из отдельной реализации внутри теста):
|
берётся из production‑генератора, а не из отдельной реализации внутри теста):
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -35,12 +35,19 @@ func run(cmd *cobra.Command, args []string) {
|
|||||||
fmt.Println(err.Error())
|
fmt.Println(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
for {
|
// Цикла `for { runServer() }` здесь больше нет.
|
||||||
|
//
|
||||||
|
// Он существовал ради единственного сценария: смена RESET_TRAFFIC_CRON
|
||||||
|
// делала StopServer(), runServer возвращался, и цикл поднимал сервис
|
||||||
|
// заново, чтобы новое расписание попало в новый планировщик. Это был
|
||||||
|
// внутрипроцессный «перезапуск», из-за которого планировщики накапливались
|
||||||
|
// (см. service/cron_scheduler.go). Расписание теперь переносится на месте,
|
||||||
|
// перезапускать сервер незачем, а перезапуском упавшего юнита занимается
|
||||||
|
// systemd (Restart=on-failure) — он для этого и предназначен.
|
||||||
if err := runServer(port); err != nil {
|
if err := runServer(port); err != nil {
|
||||||
fmt.Println(err.Error())
|
fmt.Println(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
|
|||||||
+15
-2
@@ -21,13 +21,26 @@ func init() {
|
|||||||
rootCmd.AddCommand(resetCmd)
|
rootCmd.AddCommand(resetCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Длины восстановительных учётных данных.
|
||||||
|
//
|
||||||
|
// Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36
|
||||||
|
// бит, то есть ровно нижняя граница, которую пропускает HashPassword, и
|
||||||
|
// единственной защитой оставался force_password_change. Пароль восстановления
|
||||||
|
// живёт до первого входа администратора, но до этого входа он полноценно
|
||||||
|
// пускает в панель, поэтому его стойкость должна соответствовать остальным
|
||||||
|
// секретам продукта, а не минимально допустимой.
|
||||||
|
const (
|
||||||
|
resetUsernameLength = 12
|
||||||
|
resetPasswordLength = 24
|
||||||
|
)
|
||||||
|
|
||||||
func runReset(cmd *cobra.Command, args []string) {
|
func runReset(cmd *cobra.Command, args []string) {
|
||||||
username, err := util.RandomString(6)
|
username, err := util.RandomString(resetUsernameLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err.Error())
|
fmt.Println(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
password, err := util.RandomString(6)
|
password, err := util.RandomString(resetPasswordLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err.Error())
|
fmt.Println(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+44
-6
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,9 +15,11 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
func resolveUiBindHost() (string, error) {
|
func resolveUiBindHost() (string, error) {
|
||||||
@@ -69,9 +72,14 @@ func runServer(port string) error {
|
|||||||
if err := dao.InitSql(); err != nil {
|
if err := dao.InitSql(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := middleware.InitCron(); err != nil {
|
if err := service.InitCron(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Порядок остановки обратный порядку регистрации: планировщик глушится
|
||||||
|
// РАНЬШЕ, чем releaseResource закроет SQLite. Каждая джоба ходит в базу,
|
||||||
|
// и обратный порядок означал бы гарантированные «database is closed» на
|
||||||
|
// каждом штатном завершении сервиса.
|
||||||
|
defer service.StopCron()
|
||||||
if err := service.InitHysteria2(); err != nil {
|
if err := service.InitHysteria2(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -97,19 +105,49 @@ func runServer(port string) error {
|
|||||||
// loopback и публикуется через SSH-туннель или reverse proxy, на котором
|
// loopback и публикуется через SSH-туннель или reverse proxy, на котором
|
||||||
// TLS и заканчивается.
|
// TLS и заканчивается.
|
||||||
service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r)
|
service.InitServer(fmt.Sprintf("%s:%d", bindHost, uiPort), r)
|
||||||
if err := service.StartServer(); err != nil && err != http.ErrServerClosed {
|
|
||||||
|
// Штатное завершение по сигналу.
|
||||||
|
//
|
||||||
|
// Раньше 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyServeError отделяет штатное закрытие сервера от настоящего отказа.
|
||||||
|
func classifyServeError(err error) error {
|
||||||
|
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
logrus.Errorf("start server err: %v", err)
|
logrus.Errorf("start server err: %v", err)
|
||||||
return errors.New("start server err")
|
return errors.New("start server err")
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func releaseResource() {
|
func releaseResource() {
|
||||||
if err := dao.CloseSqliteDB(); err != nil {
|
if err := dao.CloseSqliteDB(); err != nil {
|
||||||
logrus.Errorf(err.Error())
|
logrus.Errorf("%v", err)
|
||||||
}
|
}
|
||||||
if err := service.ReleaseHysteria2(); err != nil {
|
if err := service.ReleaseHysteria2(); err != nil {
|
||||||
logrus.Errorf(err.Error())
|
logrus.Errorf("%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-16
@@ -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) {
|
func UpdateConfigs(c *gin.Context) {
|
||||||
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
|
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
needRestart := false
|
updates := make([]service.ConfigUpdate, 0, len(configsUpdateDto.ConfigUpdateDtos))
|
||||||
|
seen := make(map[string]struct{}, len(configsUpdateDto.ConfigUpdateDtos))
|
||||||
|
|
||||||
for _, item := range configsUpdateDto.ConfigUpdateDtos {
|
for _, item := range configsUpdateDto.ConfigUpdateDtos {
|
||||||
key := *item.Key
|
key := *item.Key
|
||||||
@@ -63,42 +81,67 @@ func UpdateConfigs(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if key == constant.ResetTrafficCron {
|
// Один ключ дважды в одной партии — неоднозначный запрос: какое из
|
||||||
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
|
// двух значений считать намерением оператора, определить нельзя.
|
||||||
if err != nil {
|
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.ValidateConfigValue(key, value); err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
vo.Fail(err.Error(), c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if *resetTrafficCron.Value != value {
|
|
||||||
needRestart = true
|
updates = append(updates, service.ConfigUpdate{Key: key, Value: value})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = service.UpdateConfig(key, value); err != nil {
|
if err = service.UpdateConfigs(updates); err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
vo.Fail(err.Error(), c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if needRestart {
|
if err = applyRuntimeConfigUpdates(updates); err != nil {
|
||||||
go func() {
|
vo.Fail(err.Error(), c)
|
||||||
_ = service.StopServer()
|
return
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vo.Success(nil, c)
|
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-оркестратор.
|
// isOrchestratorManagedConfigKey — ключи, которыми владеет install-оркестратор.
|
||||||
//
|
//
|
||||||
// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять
|
// Формально они и так не входят в allowlist, но отказ по ним обязан объяснять
|
||||||
// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого
|
// ПОЧЕМУ: «этим значением владеет оркестратор» — это другой ответ, чем «такого
|
||||||
// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
|
// ключа в API нет», и он ведёт оператора к `hy2xs-orchestrator reconfigure`.
|
||||||
|
//
|
||||||
|
// HYSTERIA2_ENABLE и HYSTERIA2_CONFIG отсюда убраны вместе с самими ключами:
|
||||||
|
// первым никто не управлял, второй был вторым источником истины рядом с
|
||||||
|
// /etc/hysteria/config.yaml. Serverный конфиг по-прежнему принадлежит
|
||||||
|
// оркестратору — просто теперь он живёт только в файле, а не ещё и в SQLite.
|
||||||
func isOrchestratorManagedConfigKey(key string) bool {
|
func isOrchestratorManagedConfigKey(key string) bool {
|
||||||
switch key {
|
switch key {
|
||||||
case constant.Hysteria2Enable,
|
case constant.Hysteria2TrafficStatsSecret:
|
||||||
constant.Hysteria2Config,
|
|
||||||
constant.Hysteria2TrafficStatsSecret:
|
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
|
"hy2xs-admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Секреты, которые лежат в таблице `config` рядом с пользовательскими
|
// Секреты, которые лежат в таблице `config` рядом с пользовательскими
|
||||||
@@ -72,7 +75,7 @@ func TestListConfigRefusesInternalKeys(t *testing.T) {
|
|||||||
// Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос.
|
// Секретный ключ, спрятанный среди разрешённых, обязан отклонить весь запрос.
|
||||||
func TestListConfigRefusesMixedBatch(t *testing.T) {
|
func TestListConfigRefusesMixedBatch(t *testing.T) {
|
||||||
result := postJSON(t, ListConfig, "/config/listConfig", map[string]any{
|
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" {
|
if result.Type != "no" {
|
||||||
@@ -109,8 +112,6 @@ func TestUpdateConfigsRefusesInternalKeys(t *testing.T) {
|
|||||||
// такого ключа»: оператор должен понять, что менять их надо через reconfigure.
|
// такого ключа»: оператор должен понять, что менять их надо через reconfigure.
|
||||||
func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
|
func TestUpdateConfigsPointsAtOrchestratorForItsOwnKeys(t *testing.T) {
|
||||||
for _, key := range []string{
|
for _, key := range []string{
|
||||||
constant.Hysteria2Enable,
|
|
||||||
constant.Hysteria2Config,
|
|
||||||
constant.Hysteria2TrafficStatsSecret,
|
constant.Hysteria2TrafficStatsSecret,
|
||||||
} {
|
} {
|
||||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
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) {
|
func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
|
||||||
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||||
"configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}},
|
"configUpdateDtos": []map[string]string{{"key": "TOTALLY_UNKNOWN_KEY", "value": "x"}},
|
||||||
@@ -139,17 +157,146 @@ func TestUpdateConfigsRefusesUnknownKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отказ на любой записи партии обязан отменить партию целиком: иначе первые
|
// newConfigTestDB поднимает настоящую SQLite: проверка «партия не применилась
|
||||||
// ключи применились бы, а оператор получил бы ошибку.
|
// частично» без базы бессмысленна — именно состояние базы и есть предмет
|
||||||
func TestUpdateConfigsRefusesWholeBatchOnFirstForbiddenKey(t *testing.T) {
|
// утверждения. Схема поднимается отдельно от 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{
|
result := postJSON(t, UpdateConfigs, "/config/updateConfigs", map[string]any{
|
||||||
"configUpdateDtos": []map[string]string{
|
"configUpdateDtos": []map[string]string{
|
||||||
|
{"key": constant.ResetTrafficCron, "value": "@weekly"},
|
||||||
{"key": constant.JwtSecret, "value": "attacker-controlled"},
|
{"key": constant.JwtSecret, "value": "attacker-controlled"},
|
||||||
{"key": constant.Hysteria2TrafficTime, "value": "10"},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if result.Type != "no" {
|
if result.Type != "no" {
|
||||||
t.Fatalf("партия с секретом не отклонена: %+v", result)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-10
@@ -43,6 +43,14 @@ func LogSystem(c *gin.Context) {
|
|||||||
vo.Fail("Unable to unmarshal log data", c)
|
vo.Fail("Unable to unmarshal log data", c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Собственный журнал санитизируется так же, как чужой.
|
||||||
|
//
|
||||||
|
// Раньше через SanitizeLogText проходил только журнал Hysteria: он
|
||||||
|
// «чужой», а свой мы якобы контролируем. Контроль этот держался на
|
||||||
|
// внимательности — ровно до logrus.Warnf с паролем администратора в
|
||||||
|
// dao/sqlite.go. Санитайз здесь стоит не вместо аккуратности в местах
|
||||||
|
// записи, а на случай следующего такого места.
|
||||||
|
logSystemVo.Msg = service.SanitizeLogText(logSystemVo.Msg)
|
||||||
logSystemVos = append(logSystemVos, logSystemVo)
|
logSystemVos = append(logSystemVos, logSystemVo)
|
||||||
}
|
}
|
||||||
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{
|
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{
|
||||||
@@ -80,10 +88,26 @@ func ExportLog(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fileName string
|
var fileName string
|
||||||
var filePath string
|
|
||||||
if *logExportDto.Option == 0 {
|
if *logExportDto.Option == 0 {
|
||||||
fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405"))
|
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 {
|
} else if *logExportDto.Option == 1 {
|
||||||
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
|
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
|
||||||
output, exportErr := service.ExportHysteriaJournalLogs(5000)
|
output, exportErr := service.ExportHysteriaJournalLogs(5000)
|
||||||
@@ -98,12 +122,9 @@ func ExportLog(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !util.Exists(filePath) {
|
// Обе ветки выше завершаются return. Сюда попадает только неизвестное
|
||||||
vo.Fail("log file not exist", c)
|
// значение option, и это отказ, а не отдача пустого файла: раньше здесь
|
||||||
return
|
// оставался путь с пустым filePath, который сводился к тому же сообщению
|
||||||
}
|
// окольной дорогой.
|
||||||
c.Header("Content-Type", "application/octet-stream")
|
vo.Fail("unsupported log export option", c)
|
||||||
c.Header("Content-Transfer-Encoding", "binary")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
|
||||||
c.File(filePath)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-3
@@ -18,15 +18,101 @@ func SaveConfig(config entity.Config) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateConfig(keys []string, updates map[string]interface{}) error {
|
func UpdateConfig(keys []string, updates map[string]interface{}) error {
|
||||||
if len(updates) > 0 {
|
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")
|
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||||
if tx := sqliteDB.Model(&entity.Config{}).
|
if tx := db.Model(&entity.Config{}).
|
||||||
Where("key in ?", keys).
|
Where("key in ?", keys).
|
||||||
Updates(updates); tx.Error != nil {
|
Updates(updates); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return errors.New(constant.SysError)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+131
-86
@@ -95,6 +95,22 @@ func envBoolAsInt(name string, fallback int) int {
|
|||||||
return fallback
|
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 {
|
func ensureSecureBootstrapAdmin() error {
|
||||||
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
|
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
|
||||||
if adminUser == "" {
|
if adminUser == "" {
|
||||||
@@ -105,14 +121,16 @@ func ensureSecureBootstrapAdmin() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
||||||
passwordGenerated := false
|
|
||||||
if adminPassword == "" {
|
if adminPassword == "" {
|
||||||
password, pwdErr := util.RandomString(18)
|
return errors.New(
|
||||||
if pwdErr != nil {
|
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
|
||||||
return pwdErr
|
"Это признак повреждённого контракта запуска: пароль первого администратора\n" +
|
||||||
}
|
"задаёт install-оркестратор через /etc/hy2xs/hy2xs.env, а копию кладёт в\n" +
|
||||||
adminPassword = password
|
"/etc/hy2xs/bootstrap-admin.secret. Придумывать его самостоятельно админка не\n" +
|
||||||
passwordGenerated = true
|
"будет: такой пароль не знал бы никто, кроме журнала.\n" +
|
||||||
|
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
|
||||||
|
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
||||||
status := int64(1)
|
status := int64(1)
|
||||||
@@ -135,23 +153,22 @@ func ensureSecureBootstrapAdmin() error {
|
|||||||
if _, saveErr := SaveAdminUser(account); saveErr != nil {
|
if _, saveErr := SaveAdminUser(account); saveErr != nil {
|
||||||
return saveErr
|
return saveErr
|
||||||
}
|
}
|
||||||
if passwordGenerated {
|
|
||||||
logrus.Warnf("Initial admin username: %s", adminUser)
|
|
||||||
logrus.Warnf("Initial admin password: %s", adminPassword)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureSecureBootstrapPeer создаёт пир установщика.
|
||||||
|
//
|
||||||
|
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и
|
||||||
|
// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё
|
||||||
|
// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора
|
||||||
|
// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с
|
||||||
|
// файлом, и первая же проверка подключения после установки провалилась бы —
|
||||||
|
// на корректном во всём остальном сервере.
|
||||||
|
//
|
||||||
|
// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать
|
||||||
|
// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса
|
||||||
|
// без переменной падал бы на ровном месте.
|
||||||
func ensureSecureBootstrapPeer() error {
|
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 {
|
if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -159,6 +176,19 @@ func ensureSecureBootstrapPeer() error {
|
|||||||
return nil
|
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)
|
secretDigest, err := buildPeerSecretDigest(bootstrapSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -205,15 +235,23 @@ func ensureSecureBootstrapPeer() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildPeerSecretDigest(rawSecret string) (string, error) {
|
func buildPeerSecretDigest(rawSecret string) (string, error) {
|
||||||
secretKey, err := getOrCreatePeerSecretDigestKey()
|
secretKey, err := GetOrCreatePeerSecretDigestKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return util.HmacSHA256Hex(rawSecret, secretKey), nil
|
return util.HmacSHA256Hex(rawSecret, secretKey), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
|
// GetOrCreatePeerSecretEncryptionKey возвращает master-key шифрования секретов
|
||||||
raw, err := getOrCreateConfigKey(constant.PeerSecretEncryptionKey, 32)
|
// пиров, при необходимости создавая его.
|
||||||
|
//
|
||||||
|
// Экспортирована, чтобы у ключа была ровно одна реализация. Раньше идентичная
|
||||||
|
// копия жила в service/peer_secret.go: две функции с одинаковым именем в разных
|
||||||
|
// пакетах, обе создающие один и тот же материал шифрования. Расхождение между
|
||||||
|
// ними означало бы, что секреты пиров шифруются одним ключом, а
|
||||||
|
// расшифровываются другим, и обнаружилось бы это на живых пирах.
|
||||||
|
func GetOrCreatePeerSecretEncryptionKey() ([]byte, error) {
|
||||||
|
raw, err := GetOrCreateConfigSecret(constant.PeerSecretEncryptionKey, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -221,41 +259,42 @@ func getOrCreatePeerSecretEncryptionKey() ([]byte, error) {
|
|||||||
if decErr == nil {
|
if decErr == nil {
|
||||||
return decoded, nil
|
return decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ключ, записанный не в base64: приводим к стабильному представлению один
|
||||||
|
// раз, сохранив исходные байты, — иначе уже зашифрованные секреты пиров
|
||||||
|
// перестали бы расшифровываться.
|
||||||
plain := []byte(strings.TrimSpace(raw))
|
plain := []byte(strings.TrimSpace(raw))
|
||||||
if len(plain) < 32 {
|
if len(plain) < 32 {
|
||||||
return nil, errors.New("invalid peer secret encryption key")
|
return nil, errors.New("invalid peer secret encryption key")
|
||||||
}
|
}
|
||||||
plain = plain[:32]
|
plain = plain[:32]
|
||||||
encoded := base64.StdEncoding.EncodeToString(plain)
|
if err := UpsertConfigValue(constant.PeerSecretEncryptionKey, base64.StdEncoding.EncodeToString(plain)); err != nil {
|
||||||
if err := UpdateConfig([]string{constant.PeerSecretEncryptionKey}, map[string]interface{}{"value": encoded}); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return plain, nil
|
return plain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func encryptBootstrapPeerSecret(rawSecret string) (string, error) {
|
func encryptBootstrapPeerSecret(rawSecret string) (string, error) {
|
||||||
key, err := getOrCreatePeerSecretEncryptionKey()
|
key, err := GetOrCreatePeerSecretEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return util.EncryptAESGCM(rawSecret, key)
|
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)
|
cfg, err := GetConfig("key = ?", key)
|
||||||
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
|
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
|
||||||
return strings.TrimSpace(*cfg.Value), nil
|
return strings.TrimSpace(*cfg.Value), nil
|
||||||
}
|
}
|
||||||
raw, genErr := util.RandomString(size)
|
value, genErr := util.RandomString(size)
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
return "", genErr
|
return "", genErr
|
||||||
}
|
}
|
||||||
value := raw
|
if err := UpsertConfigValue(key, value); err != nil {
|
||||||
remark := key
|
return "", err
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
@@ -300,6 +339,7 @@ func runMigrations() error {
|
|||||||
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
|
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
|
||||||
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
|
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
|
||||||
{version: "005_metric_sample", apply: migrateMetricSampleTable},
|
{version: "005_metric_sample", apply: migrateMetricSampleTable},
|
||||||
|
{version: "006_drop_dead_config_keys", apply: migrateDropDeadConfigKeys},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
@@ -322,12 +362,16 @@ func runMigrations() error {
|
|||||||
|
|
||||||
func seedBaseConfig() 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{
|
defaults := map[string]string{
|
||||||
constant.JwtSecret: "",
|
constant.JwtSecret: "",
|
||||||
constant.Hysteria2Enable: "0",
|
|
||||||
constant.Hysteria2Config: "",
|
|
||||||
constant.Hysteria2TrafficTime: "10",
|
|
||||||
constant.Hysteria2ConfigRemark: "",
|
|
||||||
constant.ResetTrafficCron: "0 0 * * *",
|
constant.ResetTrafficCron: "0 0 * * *",
|
||||||
constant.Hysteria2TrafficStatsSecret: "",
|
constant.Hysteria2TrafficStatsSecret: "",
|
||||||
constant.PeerSecretKey: "",
|
constant.PeerSecretKey: "",
|
||||||
@@ -363,6 +407,34 @@ func migrateBaseConfig() error {
|
|||||||
return seedBaseConfig()
|
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) {
|
func migrationApplied(version string) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil {
|
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 {
|
if secErr != nil {
|
||||||
return "", secErr
|
return "", secErr
|
||||||
}
|
}
|
||||||
if err := UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": secret}); err != nil {
|
if err := UpsertConfigValue(constant.JwtSecret, secret); err != nil {
|
||||||
key := constant.JwtSecret
|
return "", err
|
||||||
remark := "JWT signing secret"
|
|
||||||
if _, saveErr := SaveConfig(entity.Config{Key: &key, Value: &secret, Remark: &remark}); saveErr != nil {
|
|
||||||
return "", saveErr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return secret, nil
|
return secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOrCreatePeerSecretDigestKey() (string, error) {
|
// GetOrCreatePeerSecretDigestKey — HMAC-ключ, которым считается secret_digest
|
||||||
if existing, err := GetConfig("key = ?", constant.PeerSecretKey); err == nil {
|
// пира. Экспортирована по той же причине, что и ключ шифрования: копия этой же
|
||||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
// логики жила в service/peer_secret.go под именем GetPeerSecretKey.
|
||||||
return strings.TrimSpace(*existing.Value), nil
|
func GetOrCreatePeerSecretDigestKey() (string, error) {
|
||||||
}
|
return GetOrCreateConfigSecret(constant.PeerSecretKey, 48)
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateTrafficTables() error {
|
func migrateTrafficTables() error {
|
||||||
@@ -542,43 +597,33 @@ func tableExists(tableName string) bool {
|
|||||||
}
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
// ensureTrafficStatsSecret синхронизирует machine token с окружением.
|
||||||
|
//
|
||||||
|
// Владелец значения — оркестратор: он же кладёт этот секрет в
|
||||||
|
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
|
||||||
|
// /internal/hysteria/auth. Поэтому env всегда побеждает содержимое базы, а
|
||||||
|
// собственная генерация — только для случая, когда переменной нет вовсе.
|
||||||
|
//
|
||||||
|
// Четыре почти одинаковые ветки INSERT/UPDATE свёрнуты в UpsertConfigValue:
|
||||||
|
// каждая из них по-своему решала «есть строка или нет», и две из четырёх
|
||||||
|
// решали неверно (см. комментарий к UpsertConfigValue).
|
||||||
func ensureTrafficStatsSecret() error {
|
func ensureTrafficStatsSecret() error {
|
||||||
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
||||||
if envSecret != "" {
|
if envSecret != "" {
|
||||||
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
|
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
|
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
|
||||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
secret, secErr := util.RandomString(32)
|
secret, secErr := util.RandomString(32)
|
||||||
if secErr != nil {
|
if secErr != nil {
|
||||||
return secErr
|
return secErr
|
||||||
}
|
}
|
||||||
return UpdateConfig([]string{constant.Hysteria2TrafficStatsSecret}, map[string]interface{}{"value": secret})
|
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func CloseSqliteDB() error {
|
func CloseSqliteDB() error {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^1.0.0",
|
"@element-plus/icons-vue": "^1.0.0",
|
||||||
"@vueuse/core": "^9.1.1",
|
"@vueuse/core": "^9.1.1",
|
||||||
"axios": "^1.3.4",
|
"axios": "^1.20.0",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
"element-plus": "^2.3.1",
|
"element-plus": "^2.3.1",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-vue": "^9.9.0",
|
"eslint-plugin-vue": "^9.9.0",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.5.26",
|
||||||
"postcss-html": "^1.5.0",
|
"postcss-html": "^1.5.0",
|
||||||
"postcss-scss": "^4.0.6",
|
"postcss-scss": "^4.0.6",
|
||||||
"prettier": "^2.8.7",
|
"prettier": "^2.8.7",
|
||||||
@@ -63,6 +63,12 @@
|
|||||||
"vite-plugin-svg-icons": "^2.0.1",
|
"vite-plugin-svg-icons": "^2.0.1",
|
||||||
"vue-tsc": "^0.35.0"
|
"vue-tsc": "^0.35.0"
|
||||||
},
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"lodash": ">=4.18.1",
|
||||||
|
"lodash-es": ">=4.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.12.0"
|
"node": ">=18.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+212
-77
@@ -4,6 +4,10 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
lodash: '>=4.18.1'
|
||||||
|
lodash-es: '>=4.18.1'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -15,8 +19,8 @@ importers:
|
|||||||
specifier: ^9.1.1
|
specifier: ^9.1.1
|
||||||
version: 9.1.1(vue@3.2.45)
|
version: 9.1.1(vue@3.2.45)
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.3.4
|
specifier: ^1.20.0
|
||||||
version: 1.3.4
|
version: 1.20.0
|
||||||
copy-to-clipboard:
|
copy-to-clipboard:
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 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)
|
version: 4.0.0(vite@4.3.1(@types/node@20.7.0)(sass@1.58.3))(vue@3.2.45)
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.13
|
specifier: ^10.4.13
|
||||||
version: 10.4.13(postcss@8.4.21)
|
version: 10.4.13(postcss@8.5.26)
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.34.0
|
specifier: ^8.34.0
|
||||||
version: 8.34.0
|
version: 8.34.0
|
||||||
@@ -88,14 +92,14 @@ importers:
|
|||||||
specifier: ^9.9.0
|
specifier: ^9.9.0
|
||||||
version: 9.9.0(eslint@8.34.0)
|
version: 9.9.0(eslint@8.34.0)
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.4.21
|
specifier: ^8.5.26
|
||||||
version: 8.4.21
|
version: 8.5.26
|
||||||
postcss-html:
|
postcss-html:
|
||||||
specifier: ^1.5.0
|
specifier: ^1.5.0
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
postcss-scss:
|
postcss-scss:
|
||||||
specifier: ^4.0.6
|
specifier: ^4.0.6
|
||||||
version: 4.0.6(postcss@8.4.21)
|
version: 4.0.6(postcss@8.5.26)
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^2.8.7
|
specifier: ^2.8.7
|
||||||
version: 2.8.7
|
version: 2.8.7
|
||||||
@@ -113,7 +117,7 @@ importers:
|
|||||||
version: 4.0.0(stylelint@15.5.0(typescript@4.9.3))
|
version: 4.0.0(stylelint@15.5.0(typescript@4.9.3))
|
||||||
stylelint-config-recommended-scss:
|
stylelint-config-recommended-scss:
|
||||||
specifier: ^9.0.1
|
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:
|
stylelint-config-recommended-vue:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3))
|
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))
|
version: 32.0.0(stylelint@15.5.0(typescript@4.9.3))
|
||||||
stylelint-config-standard-scss:
|
stylelint-config-standard-scss:
|
||||||
specifier: ^8.0.0
|
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:
|
typescript:
|
||||||
specifier: ^4.9.3
|
specifier: ^4.9.3
|
||||||
version: 4.9.3
|
version: 4.9.3
|
||||||
@@ -532,8 +536,8 @@ packages:
|
|||||||
rollup:
|
rollup:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@sxzz/popperjs-es@2.11.7':
|
'@sxzz/popperjs-es@2.11.8':
|
||||||
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
|
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
|
||||||
|
|
||||||
'@trysound/sax@0.2.0':
|
'@trysound/sax@0.2.0':
|
||||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||||
@@ -881,8 +885,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.1.0
|
postcss: ^8.1.0
|
||||||
|
|
||||||
axios@1.3.4:
|
axios@1.20.0:
|
||||||
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==}
|
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
@@ -937,6 +941,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==}
|
resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
callsites@3.1.0:
|
||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1189,6 +1197,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==}
|
resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
duplexer@0.1.2:
|
duplexer@0.1.2:
|
||||||
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
|
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
|
||||||
|
|
||||||
@@ -1231,6 +1243,22 @@ packages:
|
|||||||
error-ex@1.3.2:
|
error-ex@1.3.2:
|
||||||
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
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:
|
esbuild@0.17.19:
|
||||||
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
|
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1414,8 +1442,8 @@ packages:
|
|||||||
flatted@3.2.9:
|
flatted@3.2.9:
|
||||||
resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==}
|
resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==}
|
||||||
|
|
||||||
follow-redirects@1.15.3:
|
follow-redirects@1.16.0:
|
||||||
resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==}
|
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
debug: '*'
|
debug: '*'
|
||||||
@@ -1427,8 +1455,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==}
|
resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
form-data@4.0.0:
|
form-data@4.0.6:
|
||||||
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
|
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fraction.js@4.3.6:
|
fraction.js@4.3.6:
|
||||||
@@ -1457,10 +1485,21 @@ packages:
|
|||||||
function-bind@1.1.1:
|
function-bind@1.1.1:
|
||||||
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
|
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
|
||||||
|
|
||||||
|
function-bind@1.1.2:
|
||||||
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2:
|
gensync@1.0.0-beta.2:
|
||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
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:
|
get-stream@6.0.1:
|
||||||
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
|
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1512,6 +1551,10 @@ packages:
|
|||||||
globjoin@0.1.4:
|
globjoin@0.1.4:
|
||||||
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
|
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
|
||||||
|
|
||||||
|
gopd@1.2.0:
|
||||||
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
@@ -1542,6 +1585,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
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:
|
has-value@0.3.1:
|
||||||
resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==}
|
resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1565,6 +1616,10 @@ packages:
|
|||||||
hash-sum@2.0.0:
|
hash-sum@2.0.0:
|
||||||
resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==}
|
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:
|
he@1.2.0:
|
||||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1848,15 +1903,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
lodash-es@4.17.21:
|
lodash-es@4.18.1:
|
||||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||||
|
|
||||||
lodash-unified@1.0.3:
|
lodash-unified@1.0.3:
|
||||||
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
|
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/lodash-es': '*'
|
'@types/lodash-es': '*'
|
||||||
lodash: '*'
|
lodash: '>=4.18.1'
|
||||||
lodash-es: '*'
|
lodash-es: '>=4.18.1'
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
@@ -1864,8 +1919,8 @@ packages:
|
|||||||
lodash.truncate@4.4.2:
|
lodash.truncate@4.4.2:
|
||||||
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.18.1:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
@@ -1901,6 +1956,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
|
resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
mathml-tag-names@2.1.3:
|
||||||
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
|
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
|
||||||
|
|
||||||
@@ -2008,8 +2067,8 @@ packages:
|
|||||||
ms@2.1.2:
|
ms@2.1.2:
|
||||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||||
|
|
||||||
nanoid@3.3.6:
|
nanoid@3.3.18:
|
||||||
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -2159,6 +2218,9 @@ packages:
|
|||||||
picocolors@1.0.0:
|
picocolors@1.0.0:
|
||||||
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
|
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
picomatch@2.3.1:
|
picomatch@2.3.1:
|
||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
@@ -2228,8 +2290,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==}
|
resolution: {integrity: sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
postcss@8.4.21:
|
postcss@8.5.26:
|
||||||
resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==}
|
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
posthtml-parser@0.2.1:
|
posthtml-parser@0.2.1:
|
||||||
@@ -2265,8 +2327,9 @@ packages:
|
|||||||
process-nextick-args@2.0.1:
|
process-nextick-args@2.0.1:
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
proxy-from-env@1.1.0:
|
proxy-from-env@2.1.0:
|
||||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
prr@1.0.1:
|
prr@1.0.1:
|
||||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||||
@@ -2458,6 +2521,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
source-map-resolve@0.5.3:
|
||||||
resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==}
|
resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==}
|
||||||
deprecated: See https://github.com/lydell/source-map-resolve#deprecated
|
deprecated: See https://github.com/lydell/source-map-resolve#deprecated
|
||||||
@@ -3389,7 +3456,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
rollup: 3.29.3
|
rollup: 3.29.3
|
||||||
|
|
||||||
'@sxzz/popperjs-es@2.11.7': {}
|
'@sxzz/popperjs-es@2.11.8': {}
|
||||||
|
|
||||||
'@trysound/sax@0.2.0': {}
|
'@trysound/sax@0.2.0': {}
|
||||||
|
|
||||||
@@ -3682,7 +3749,7 @@ snapshots:
|
|||||||
'@vue/shared': 3.2.45
|
'@vue/shared': 3.2.45
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
magic-string: 0.25.9
|
magic-string: 0.25.9
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
source-map: 0.6.1
|
source-map: 0.6.1
|
||||||
|
|
||||||
'@vue/compiler-sfc@3.3.4':
|
'@vue/compiler-sfc@3.3.4':
|
||||||
@@ -3695,7 +3762,7 @@ snapshots:
|
|||||||
'@vue/shared': 3.3.4
|
'@vue/shared': 3.3.4
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
magic-string: 0.30.3
|
magic-string: 0.30.3
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
source-map-js: 1.0.2
|
source-map-js: 1.0.2
|
||||||
|
|
||||||
'@vue/compiler-ssr@3.2.45':
|
'@vue/compiler-ssr@3.2.45':
|
||||||
@@ -3843,23 +3910,25 @@ snapshots:
|
|||||||
|
|
||||||
atob@2.1.2: {}
|
atob@2.1.2: {}
|
||||||
|
|
||||||
autoprefixer@10.4.13(postcss@8.4.21):
|
autoprefixer@10.4.13(postcss@8.5.26):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.21.11
|
browserslist: 4.21.11
|
||||||
caniuse-lite: 1.0.30001539
|
caniuse-lite: 1.0.30001539
|
||||||
fraction.js: 4.3.6
|
fraction.js: 4.3.6
|
||||||
normalize-range: 0.1.2
|
normalize-range: 0.1.2
|
||||||
picocolors: 1.0.0
|
picocolors: 1.0.0
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
postcss-value-parser: 4.2.0
|
postcss-value-parser: 4.2.0
|
||||||
|
|
||||||
axios@1.3.4:
|
axios@1.20.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.3
|
follow-redirects: 1.16.0
|
||||||
form-data: 4.0.0
|
form-data: 4.0.6
|
||||||
proxy-from-env: 1.1.0
|
https-proxy-agent: 5.0.1
|
||||||
|
proxy-from-env: 2.1.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
- supports-color
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
@@ -3948,6 +4017,11 @@ snapshots:
|
|||||||
union-value: 1.0.1
|
union-value: 1.0.1
|
||||||
unset-value: 1.0.0
|
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: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
camelcase-keys@6.2.2:
|
camelcase-keys@6.2.2:
|
||||||
@@ -4197,6 +4271,12 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@16.3.1: {}
|
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: {}
|
duplexer@0.1.2: {}
|
||||||
|
|
||||||
echarts@5.6.0:
|
echarts@5.6.0:
|
||||||
@@ -4211,16 +4291,16 @@ snapshots:
|
|||||||
'@ctrl/tinycolor': 3.6.1
|
'@ctrl/tinycolor': 3.6.1
|
||||||
'@element-plus/icons-vue': 2.1.0(vue@3.2.45)
|
'@element-plus/icons-vue': 2.1.0(vue@3.2.45)
|
||||||
'@floating-ui/dom': 1.5.3
|
'@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': 4.14.199
|
||||||
'@types/lodash-es': 4.17.9
|
'@types/lodash-es': 4.17.9
|
||||||
'@vueuse/core': 9.1.1(vue@3.2.45)
|
'@vueuse/core': 9.1.1(vue@3.2.45)
|
||||||
async-validator: 4.2.5
|
async-validator: 4.2.5
|
||||||
dayjs: 1.11.10
|
dayjs: 1.11.10
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
lodash: 4.17.21
|
lodash: 4.18.1
|
||||||
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)
|
||||||
memoize-one: 6.0.0
|
memoize-one: 6.0.0
|
||||||
normalize-wheel-es: 1.2.0
|
normalize-wheel-es: 1.2.0
|
||||||
vue: 3.2.45
|
vue: 3.2.45
|
||||||
@@ -4251,6 +4331,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.2.1
|
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:
|
esbuild@0.17.19:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/android-arm': 0.17.19
|
'@esbuild/android-arm': 0.17.19
|
||||||
@@ -4505,14 +4600,16 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.2.9: {}
|
flatted@3.2.9: {}
|
||||||
|
|
||||||
follow-redirects@1.15.3: {}
|
follow-redirects@1.16.0: {}
|
||||||
|
|
||||||
for-in@1.0.2: {}
|
for-in@1.0.2: {}
|
||||||
|
|
||||||
form-data@4.0.0:
|
form-data@4.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
combined-stream: 1.0.8
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.4
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
fraction.js@4.3.6: {}
|
fraction.js@4.3.6: {}
|
||||||
@@ -4538,8 +4635,28 @@ snapshots:
|
|||||||
|
|
||||||
function-bind@1.1.1: {}
|
function-bind@1.1.1: {}
|
||||||
|
|
||||||
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.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-stream@6.0.1: {}
|
||||||
|
|
||||||
get-value@2.0.6: {}
|
get-value@2.0.6: {}
|
||||||
@@ -4608,6 +4725,8 @@ snapshots:
|
|||||||
|
|
||||||
globjoin@0.1.4: {}
|
globjoin@0.1.4: {}
|
||||||
|
|
||||||
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
grapheme-splitter@1.0.4: {}
|
grapheme-splitter@1.0.4: {}
|
||||||
@@ -4628,6 +4747,12 @@ snapshots:
|
|||||||
|
|
||||||
has-flag@4.0.0: {}
|
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:
|
has-value@0.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-value: 2.0.6
|
get-value: 2.0.6
|
||||||
@@ -4653,6 +4778,10 @@ snapshots:
|
|||||||
|
|
||||||
hash-sum@2.0.0: {}
|
hash-sum@2.0.0: {}
|
||||||
|
|
||||||
|
hasown@2.0.4:
|
||||||
|
dependencies:
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
he@1.2.0: {}
|
he@1.2.0: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
@@ -4885,19 +5014,19 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
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:
|
dependencies:
|
||||||
'@types/lodash-es': 4.17.9
|
'@types/lodash-es': 4.17.9
|
||||||
lodash: 4.17.21
|
lodash: 4.18.1
|
||||||
lodash-es: 4.17.21
|
lodash-es: 4.18.1
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.truncate@4.4.2: {}
|
lodash.truncate@4.4.2: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.18.1: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4929,6 +5058,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
object-visit: 1.0.1
|
object-visit: 1.0.1
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mathml-tag-names@2.1.3: {}
|
mathml-tag-names@2.1.3: {}
|
||||||
|
|
||||||
mdn-data@2.0.14: {}
|
mdn-data@2.0.14: {}
|
||||||
@@ -5047,7 +5178,7 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.2: {}
|
ms@2.1.2: {}
|
||||||
|
|
||||||
nanoid@3.3.6: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
nanomatch@1.2.13:
|
nanomatch@1.2.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5197,6 +5328,8 @@ snapshots:
|
|||||||
|
|
||||||
picocolors@1.0.0: {}
|
picocolors@1.0.0: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
pinia@2.0.33(typescript@4.9.3)(vue@3.2.45):
|
pinia@2.0.33(typescript@4.9.3)(vue@3.2.45):
|
||||||
@@ -5219,8 +5352,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
htmlparser2: 8.0.2
|
htmlparser2: 8.0.2
|
||||||
js-tokens: 8.0.2
|
js-tokens: 8.0.2
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
postcss-safe-parser: 6.0.0(postcss@8.4.21)
|
postcss-safe-parser: 6.0.0(postcss@8.5.26)
|
||||||
|
|
||||||
postcss-import-resolver@2.0.0:
|
postcss-import-resolver@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5234,22 +5367,22 @@ snapshots:
|
|||||||
|
|
||||||
postcss-resolve-nested-selector@0.1.1: {}
|
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:
|
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:
|
dependencies:
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
|
|
||||||
postcss-selector-parser@6.0.13:
|
postcss-selector-parser@6.0.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
postcss-sorting@8.0.2(postcss@8.4.21):
|
postcss-sorting@8.0.2(postcss@8.5.26):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
|
|
||||||
postcss-value-parser@4.2.0: {}
|
postcss-value-parser@4.2.0: {}
|
||||||
|
|
||||||
@@ -5260,11 +5393,11 @@ snapshots:
|
|||||||
source-map: 0.5.7
|
source-map: 0.5.7
|
||||||
supports-color: 3.2.3
|
supports-color: 3.2.3
|
||||||
|
|
||||||
postcss@8.4.21:
|
postcss@8.5.26:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.6
|
nanoid: 3.3.18
|
||||||
picocolors: 1.0.0
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.0.2
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
posthtml-parser@0.2.1:
|
posthtml-parser@0.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5299,7 +5432,7 @@ snapshots:
|
|||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
proxy-from-env@1.1.0: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
prr@1.0.1: {}
|
prr@1.0.1: {}
|
||||||
|
|
||||||
@@ -5487,6 +5620,8 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.0.2: {}
|
source-map-js@1.0.2: {}
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-resolve@0.5.3:
|
source-map-resolve@0.5.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
atob: 2.1.2
|
atob: 2.1.2
|
||||||
@@ -5578,23 +5713,23 @@ snapshots:
|
|||||||
stylelint: 15.5.0(typescript@4.9.3)
|
stylelint: 15.5.0(typescript@4.9.3)
|
||||||
stylelint-order: 6.0.3(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:
|
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: 15.5.0(typescript@4.9.3)
|
||||||
stylelint-config-recommended: 11.0.0(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))
|
stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3))
|
||||||
optionalDependencies:
|
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:
|
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: 15.5.0(typescript@4.9.3)
|
||||||
stylelint-config-recommended: 10.0.1(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))
|
stylelint-scss: 4.7.0(stylelint@15.5.0(typescript@4.9.3))
|
||||||
optionalDependencies:
|
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)):
|
stylelint-config-recommended-vue@1.4.0(postcss-html@1.5.0)(stylelint@15.5.0(typescript@4.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5616,13 +5751,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
stylelint: 15.5.0(typescript@4.9.3)
|
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:
|
dependencies:
|
||||||
stylelint: 15.5.0(typescript@4.9.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))
|
||||||
stylelint-config-standard: 32.0.0(stylelint@15.5.0(typescript@4.9.3))
|
stylelint-config-standard: 32.0.0(stylelint@15.5.0(typescript@4.9.3))
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
|
|
||||||
stylelint-config-standard@32.0.0(stylelint@15.5.0(typescript@4.9.3)):
|
stylelint-config-standard@32.0.0(stylelint@15.5.0(typescript@4.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5631,8 +5766,8 @@ snapshots:
|
|||||||
|
|
||||||
stylelint-order@6.0.3(stylelint@15.5.0(typescript@4.9.3)):
|
stylelint-order@6.0.3(stylelint@15.5.0(typescript@4.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
postcss-sorting: 8.0.2(postcss@8.4.21)
|
postcss-sorting: 8.0.2(postcss@8.5.26)
|
||||||
stylelint: 15.5.0(typescript@4.9.3)
|
stylelint: 15.5.0(typescript@4.9.3)
|
||||||
|
|
||||||
stylelint-scss@4.7.0(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
|
micromatch: 4.0.5
|
||||||
normalize-path: 3.0.0
|
normalize-path: 3.0.0
|
||||||
picocolors: 1.0.0
|
picocolors: 1.0.0
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
postcss-media-query-parser: 0.2.3
|
postcss-media-query-parser: 0.2.3
|
||||||
postcss-resolve-nested-selector: 0.1.1
|
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-selector-parser: 6.0.13
|
||||||
postcss-value-parser: 4.2.0
|
postcss-value-parser: 4.2.0
|
||||||
resolve-from: 5.0.0
|
resolve-from: 5.0.0
|
||||||
@@ -6010,7 +6145,7 @@ snapshots:
|
|||||||
vite@4.3.1(@types/node@20.7.0)(sass@1.58.3):
|
vite@4.3.1(@types/node@20.7.0)(sass@1.58.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.17.19
|
esbuild: 0.17.19
|
||||||
postcss: 8.4.21
|
postcss: 8.5.26
|
||||||
rollup: 3.29.3
|
rollup: 3.29.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.7.0
|
'@types/node': 20.7.0
|
||||||
@@ -6043,7 +6178,7 @@ snapshots:
|
|||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
espree: 9.6.1
|
espree: 9.6.1
|
||||||
esquery: 1.5.0
|
esquery: 1.5.0
|
||||||
lodash: 4.17.21
|
lodash: 4.18.1
|
||||||
semver: 7.5.4
|
semver: 7.5.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default {
|
|||||||
form: {
|
form: {
|
||||||
namePlaceholder: "e.g. ivan-laptop",
|
namePlaceholder: "e.g. ivan-laptop",
|
||||||
nameHint:
|
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",
|
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
|
||||||
remarkHint: "Optional operator note. It is never shown to the client.",
|
remarkHint: "Optional operator note. It is never shown to the client.",
|
||||||
secretPlaceholder: "leave empty to generate automatically",
|
secretPlaceholder: "leave empty to generate automatically",
|
||||||
@@ -185,13 +185,11 @@ export default {
|
|||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
|
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
|
||||||
hysteria2TrafficTime: "Hysteria2 Traffic Time",
|
|
||||||
resetTrafficCron: "Reset traffic schedule task",
|
resetTrafficCron: "Reset traffic schedule task",
|
||||||
resetTrafficCronTip:
|
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",
|
resetTrafficMonth: "Run once a month, midnight, first of month",
|
||||||
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
||||||
invalidTrafficTime: "Field must be a number with up to one decimal place",
|
|
||||||
},
|
},
|
||||||
monitor: {
|
monitor: {
|
||||||
cpuPercent: "CPU Usage",
|
cpuPercent: "CPU Usage",
|
||||||
@@ -229,7 +227,6 @@ export default {
|
|||||||
hysteria2Version: "Hysteria2 Version",
|
hysteria2Version: "Hysteria2 Version",
|
||||||
hysteria2Running: "Hysteria2 Status",
|
hysteria2Running: "Hysteria2 Status",
|
||||||
addOutbound: "Add Outbound",
|
addOutbound: "Add Outbound",
|
||||||
extension: "Extension",
|
|
||||||
listen: "Listen",
|
listen: "Listen",
|
||||||
tls: "TLS",
|
tls: "TLS",
|
||||||
obfs: "Obfuscation",
|
obfs: "Obfuscation",
|
||||||
@@ -245,8 +242,6 @@ export default {
|
|||||||
http: "Traffic Stats API (HTTP)",
|
http: "Traffic Stats API (HTTP)",
|
||||||
masquerade: "Masquerade",
|
masquerade: "Masquerade",
|
||||||
config: {
|
config: {
|
||||||
enable: "Enable/Disable",
|
|
||||||
remark: "Remark",
|
|
||||||
listen:
|
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.",
|
"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",
|
tlsType: "TLS type",
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default {
|
|||||||
form: {
|
form: {
|
||||||
namePlaceholder: "например, ivan-laptop",
|
namePlaceholder: "например, ivan-laptop",
|
||||||
nameHint:
|
nameHint:
|
||||||
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет.",
|
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
|
||||||
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
|
remarkPlaceholder: "например, Ноутбук Ивана, отдел продаж",
|
||||||
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
|
||||||
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
|
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
|
||||||
@@ -181,13 +181,11 @@ export default {
|
|||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
|
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
|
||||||
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
|
|
||||||
resetTrafficCron: "Расписание сброса трафика",
|
resetTrafficCron: "Расписание сброса трафика",
|
||||||
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
|
resetTrafficCronTip:
|
||||||
|
"Cron-выражение для планового сброса трафика. Пустое значение отключает автоматический сброс",
|
||||||
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
||||||
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
||||||
invalidTrafficTime:
|
|
||||||
"Поле должно быть числом максимум с одним знаком после запятой",
|
|
||||||
},
|
},
|
||||||
monitor: {
|
monitor: {
|
||||||
cpuPercent: "CPU",
|
cpuPercent: "CPU",
|
||||||
@@ -225,7 +223,6 @@ export default {
|
|||||||
hysteria2Version: "Версия Hysteria2",
|
hysteria2Version: "Версия Hysteria2",
|
||||||
hysteria2Running: "Статус Hysteria2",
|
hysteria2Running: "Статус Hysteria2",
|
||||||
addOutbound: "Добавить outbound",
|
addOutbound: "Добавить outbound",
|
||||||
extension: "Расширение",
|
|
||||||
listen: "Адрес прослушивания",
|
listen: "Адрес прослушивания",
|
||||||
tls: "TLS",
|
tls: "TLS",
|
||||||
obfs: "Маскировка",
|
obfs: "Маскировка",
|
||||||
@@ -241,8 +238,6 @@ export default {
|
|||||||
http: "Traffic Stats API (HTTP)",
|
http: "Traffic Stats API (HTTP)",
|
||||||
masquerade: "Masquerade",
|
masquerade: "Masquerade",
|
||||||
config: {
|
config: {
|
||||||
enable: "Включить/отключить",
|
|
||||||
remark: "Комментарий",
|
|
||||||
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
|
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
|
||||||
tlsType: "Тип TLS",
|
tlsType: "Тип TLS",
|
||||||
tls: {
|
tls: {
|
||||||
|
|||||||
+519
-867
File diff suppressed because it is too large
Load Diff
+53
-55
@@ -1,65 +1,63 @@
|
|||||||
// generated by unplugin-vue-components
|
// generated by unplugin-vue-components
|
||||||
// We suggest you to commit this file into source control
|
// We suggest you to commit this file into source control
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
// 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 {
|
export interface GlobalComponents {
|
||||||
ElAlert: typeof import("element-plus/es")["ElAlert"];
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAvatar: typeof import("element-plus/es")["ElAvatar"];
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
ElButton: typeof import("element-plus/es")["ElButton"];
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import("element-plus/es")["ElCard"];
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCol: typeof import("element-plus/es")["ElCol"];
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElDatePicker: typeof import("element-plus/es")["ElDatePicker"];
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDialog: typeof import("element-plus/es")["ElDialog"];
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDrawer: typeof import("element-plus/es")["ElDrawer"];
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElDropdown: typeof import("element-plus/es")["ElDropdown"];
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import("element-plus/es")["ElDropdownItem"];
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
ElDropdownMenu: typeof import("element-plus/es")["ElDropdownMenu"];
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
ElEmpty: typeof import("element-plus/es")["ElEmpty"];
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElForm: typeof import("element-plus/es")["ElForm"];
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import("element-plus/es")["ElFormItem"];
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElInput: typeof import("element-plus/es")["ElInput"];
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import("element-plus/es")["ElInputNumber"];
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMenu: typeof import("element-plus/es")["ElMenu"];
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import("element-plus/es")["ElMenuItem"];
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import("element-plus/es")["ElOption"];
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import("element-plus/es")["ElPagination"];
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElProgress: typeof import("element-plus/es")["ElProgress"];
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadioButton: typeof import("element-plus/es")["ElRadioButton"];
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import("element-plus/es")["ElRadioGroup"];
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElRow: typeof import("element-plus/es")["ElRow"];
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElScrollbar: typeof import("element-plus/es")["ElScrollbar"];
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import("element-plus/es")["ElSelect"];
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSubMenu: typeof import("element-plus/es")["ElSubMenu"];
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import("element-plus/es")["ElSwitch"];
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import("element-plus/es")["ElTable"];
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import("element-plus/es")["ElTableColumn"];
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTabPane: typeof import("element-plus/es")["ElTabPane"];
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import("element-plus/es")["ElTabs"];
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import("element-plus/es")["ElTag"];
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTooltip: typeof import("element-plus/es")["ElTooltip"];
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElUpload: typeof import("element-plus/es")["ElUpload"];
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
Hamburger: typeof import("./../components/Hamburger/index.vue")["default"];
|
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
||||||
IEpCaretBottom: typeof import("~icons/ep/caret-bottom")["default"];
|
IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default']
|
||||||
IEpDownload: typeof import("~icons/ep/download")["default"];
|
IEpDownload: typeof import('~icons/ep/download')['default']
|
||||||
IEpMoreFilled: typeof import("~icons/ep/more-filled")["default"];
|
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
|
||||||
IEpRefresh: typeof import("~icons/ep/refresh")["default"];
|
IEpRefresh: typeof import('~icons/ep/refresh')['default']
|
||||||
IEpRefreshRight: typeof import("~icons/ep/refresh-right")["default"];
|
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
||||||
IEpUpload: typeof import("~icons/ep/upload")["default"];
|
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
|
||||||
IEpUserFilled: typeof import("~icons/ep/user-filled")["default"];
|
LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
|
||||||
ImputMultiple: typeof import("./../components/ImputMultiple/index.vue")["default"];
|
MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
|
||||||
LangSelect: typeof import("./../components/LangSelect/index.vue")["default"];
|
Pagination: typeof import('./../components/Pagination/index.vue')['default']
|
||||||
MapAdd: typeof import("./../components/MapAdd/index.vue")["default"];
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
Pagination: typeof import("./../components/Pagination/index.vue")["default"];
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
RouterLink: typeof import("vue-router")["RouterLink"];
|
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
|
||||||
RouterView: typeof import("vue-router")["RouterView"];
|
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
|
||||||
SvgIcon: typeof import("./../components/SvgIcon/index.vue")["default"];
|
|
||||||
UnitSelect: typeof import("./../components/UnitSelect/index.vue")["default"];
|
|
||||||
}
|
}
|
||||||
export interface ComponentCustomProperties {
|
export interface ComponentCustomProperties {
|
||||||
vLoading: typeof import("element-plus/es")["ElLoadingDirective"];
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,6 @@
|
|||||||
:model="dataForm"
|
:model="dataForm"
|
||||||
label-position="top"
|
label-position="top"
|
||||||
>
|
>
|
||||||
<el-form-item
|
|
||||||
:label="$t('config.hysteria2TrafficTime')"
|
|
||||||
prop="hysteria2TrafficTime"
|
|
||||||
>
|
|
||||||
<el-input
|
|
||||||
v-model="dataForm.hysteria2TrafficTime"
|
|
||||||
:placeholder="$t('config.hysteria2TrafficTime')"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
:content="$t('config.resetTrafficCronTip')"
|
:content="$t('config.resetTrafficCronTip')"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
@@ -79,6 +69,14 @@ export default {
|
|||||||
//
|
//
|
||||||
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
|
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
|
||||||
// целиком, вместе с JWT- и peer-ключами.
|
// целиком, вместе с JWT- и peer-ключами.
|
||||||
|
//
|
||||||
|
// Поля «Период учёта трафика Hysteria2» здесь тоже больше нет. Ключ
|
||||||
|
// HYSTERIA2_TRAFFIC_TIME не имел в рантайме ни одного потребителя: интервал
|
||||||
|
// сбора метрик задан в коде админки, а traffic accounting его не читал.
|
||||||
|
// Настройка меняла строку в базе и ничего больше — то есть панель обещала
|
||||||
|
// оператору управление, которого не существовало.
|
||||||
|
//
|
||||||
|
// Осталась ровно одна настройка, которой панель действительно владеет.
|
||||||
import { Select } from "@element-plus/icons-vue";
|
import { Select } from "@element-plus/icons-vue";
|
||||||
import { listConfigApi, updateConfigsApi } from "@/api/config";
|
import { listConfigApi, updateConfigsApi } from "@/api/config";
|
||||||
import { ConfigsUpdateDto } from "@/api/config/types";
|
import { ConfigsUpdateDto } from "@/api/config/types";
|
||||||
@@ -88,7 +86,6 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const dataFormRef = ref(ElForm);
|
const dataFormRef = ref(ElForm);
|
||||||
|
|
||||||
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
|
|
||||||
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
|
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
|
||||||
|
|
||||||
const cronResetTraffic = [
|
const cronResetTraffic = [
|
||||||
@@ -96,24 +93,18 @@ const cronResetTraffic = [
|
|||||||
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
|
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const dataFormRules = {
|
// Правил формы здесь нет намеренно.
|
||||||
hysteria2TrafficTime: [
|
//
|
||||||
{
|
// Поле — el-select с allow-create, то есть значение произвольное, и
|
||||||
required: true,
|
// единственная осмысленная проверка cron-выражения — разбор его тем же
|
||||||
message: t("common.required"),
|
// парсером, которым пользуется планировщик. Такой парсер живёт на сервере;
|
||||||
trigger: ["change", "blur"],
|
// вторая, приблизительная реализация на фронтенде расходилась бы с ним и
|
||||||
},
|
// давала бы либо ложные отказы, либо ложные разрешения. Пустое значение
|
||||||
{
|
// легально и означает «автоматический сброс выключен».
|
||||||
pattern: /^\d+(\.\d)?$/,
|
const dataFormRules = {};
|
||||||
message: t("config.invalidTrafficTime"),
|
|
||||||
trigger: ["change", "blur"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
dataForm: {
|
dataForm: {
|
||||||
hysteria2TrafficTime: "1",
|
|
||||||
resetTrafficCron: "",
|
resetTrafficCron: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -124,10 +115,6 @@ const submitForm = () => {
|
|||||||
dataFormRef.value.validate((valid: boolean) => {
|
dataFormRef.value.validate((valid: boolean) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
let configs: ConfigsUpdateDto[] = [
|
let configs: ConfigsUpdateDto[] = [
|
||||||
{
|
|
||||||
key: hysteria2TrafficTimeKey,
|
|
||||||
value: state.dataForm.hysteria2TrafficTime,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: resetTrafficCronKey,
|
key: resetTrafficCronKey,
|
||||||
value: state.dataForm.resetTrafficCron,
|
value: state.dataForm.resetTrafficCron,
|
||||||
@@ -143,13 +130,11 @@ const submitForm = () => {
|
|||||||
|
|
||||||
const setConfig = async () => {
|
const setConfig = async () => {
|
||||||
const { data } = await listConfigApi({
|
const { data } = await listConfigApi({
|
||||||
keys: [hysteria2TrafficTimeKey, resetTrafficCronKey],
|
keys: [resetTrafficCronKey],
|
||||||
});
|
});
|
||||||
|
|
||||||
data.forEach((configVo) => {
|
data.forEach((configVo) => {
|
||||||
if (configVo.key === hysteria2TrafficTimeKey) {
|
if (configVo.key === resetTrafficCronKey) {
|
||||||
state.dataForm.hysteria2TrafficTime = configVo.value;
|
|
||||||
} else if (configVo.key === resetTrafficCronKey) {
|
|
||||||
state.dataForm.resetTrafficCron = configVo.value;
|
state.dataForm.resetTrafficCron = configVo.value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,16 +51,6 @@
|
|||||||
:disabled="true"
|
:disabled="true"
|
||||||
>
|
>
|
||||||
<el-tabs v-model="activeName" class="tabs">
|
<el-tabs v-model="activeName" class="tabs">
|
||||||
<el-tab-pane :label="$t('hysteria.extension')" name="extension">
|
|
||||||
<el-tooltip
|
|
||||||
:content="$t('hysteria.config.remark')"
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<el-form-item label="remark" prop="remark">
|
|
||||||
<el-input v-model="configForm.remark" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
</el-tooltip>
|
|
||||||
</el-tab-pane>
|
|
||||||
<el-tab-pane :label="$t('hysteria.listen')" name="listen">
|
<el-tab-pane :label="$t('hysteria.listen')" name="listen">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
:content="$t('hysteria.config.listen')"
|
:content="$t('hysteria.config.listen')"
|
||||||
@@ -1062,18 +1052,19 @@ import {
|
|||||||
Hysteria2ServerConfig,
|
Hysteria2ServerConfig,
|
||||||
} from "@/api/config/types";
|
} from "@/api/config/types";
|
||||||
import Outbounds from "./components/Outbounds/index.vue";
|
import Outbounds from "./components/Outbounds/index.vue";
|
||||||
import {
|
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
|
||||||
exportHysteria2ConfigApi,
|
|
||||||
getHysteria2ConfigApi,
|
|
||||||
listConfigApi,
|
|
||||||
} from "@/api/config";
|
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { assignWith, deepCopy } from "@/utils/copy";
|
import { assignWith, deepCopy } from "@/utils/copy";
|
||||||
import { dashboardSummaryApi } from "@/api/dashboard";
|
import { dashboardSummaryApi } from "@/api/dashboard";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const hysteria2Remark = "HYSTERIA2_CONFIG_REMARK";
|
// Вкладки «extension» с полем remark здесь больше нет.
|
||||||
|
//
|
||||||
|
// Она показывала HYSTERIA2_CONFIG_REMARK — пустую строку в таблице `config`,
|
||||||
|
// которую clean install создавал один раз и которую не записывал никто. Имя
|
||||||
|
// профиля в клиентской ссылке теперь выводится из имени пира на сервере
|
||||||
|
// (см. service/hysteria2_api.go), поэтому настраивать здесь нечего.
|
||||||
const dataFormRef = ref(ElForm);
|
const dataFormRef = ref(ElForm);
|
||||||
|
|
||||||
const dataFormRules = {
|
const dataFormRules = {
|
||||||
@@ -1118,11 +1109,8 @@ const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
|
|||||||
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
|
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
configForm: {
|
|
||||||
remark: "",
|
|
||||||
},
|
|
||||||
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
|
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
|
||||||
activeName: "extension",
|
activeName: "listen",
|
||||||
tlsType: "acme",
|
tlsType: "acme",
|
||||||
aclType: "inline",
|
aclType: "inline",
|
||||||
obfs: false,
|
obfs: false,
|
||||||
@@ -1143,7 +1131,6 @@ const state = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
configForm,
|
|
||||||
activeName,
|
activeName,
|
||||||
dataForm,
|
dataForm,
|
||||||
tlsType,
|
tlsType,
|
||||||
@@ -1183,17 +1170,6 @@ const handleExport = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setConfig = () => {
|
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) => {
|
getHysteria2ConfigApi().then((response) => {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|||||||
+45
-39
@@ -1,67 +1,73 @@
|
|||||||
module hy2xs-admin
|
module hy2xs-admin
|
||||||
|
|
||||||
go 1.21
|
// Языковая версия держится ниже GO_VERSION из versions.env: сборка проверяет
|
||||||
|
// это шагом verify_go_toolchain_contract. 1.21 здесь стояла с поколения, в
|
||||||
|
// котором она ещё поддерживалась; к v1 её линия давно вне поддержки, а вместе
|
||||||
|
// с ней в бинарь попадала соответствующая stdlib.
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/didip/tollbooth v4.0.2+incompatible
|
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/glebarez/sqlite v1.11.0
|
||||||
github.com/go-playground/validator/v10 v10.14.0
|
github.com/go-playground/validator/v10 v10.30.3
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.10.2
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/cobra v1.8.1
|
golang.org/x/crypto v0.55.0
|
||||||
golang.org/x/crypto v0.18.0
|
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/gorm v1.25.9
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.23.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/google/go-cmp v0.5.9 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/leodido/go-urn v1.5.0 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.13 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.7.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // 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
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
|
||||||
golang.org/x/net v0.20.0 // indirect
|
golang.org/x/arch v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.16.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.75.6 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
modernc.org/sqlite v1.23.1 // indirect
|
modernc.org/sqlite v1.57.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+134
-97
@@ -1,11 +1,12 @@
|
|||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
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.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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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/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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
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.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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/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 h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||||
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
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.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.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.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.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.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.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/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
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=
|
go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
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-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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
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=
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,27 @@ package constant
|
|||||||
// HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS
|
// HY2XS_UI_PORT через systemd, bind — из HY2XS_UI_BIND_HOST, TLS
|
||||||
// терминируется на внешнем слое. Панель записывала в SQLite тот же порт,
|
// терминируется на внешнем слое. Панель записывала в 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 (
|
const (
|
||||||
JwtSecret = "JWT_SECRET"
|
JwtSecret = "JWT_SECRET"
|
||||||
PeerSecretKey = "PEER_SECRET_KEY"
|
PeerSecretKey = "PEER_SECRET_KEY"
|
||||||
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
|
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
|
||||||
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
|
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
|
||||||
Hysteria2Enable = "HYSTERIA2_ENABLE"
|
|
||||||
Hysteria2Config = "HYSTERIA2_CONFIG"
|
|
||||||
Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME"
|
|
||||||
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
|
|
||||||
ResetTrafficCron = "RESET_TRAFFIC_CRON"
|
ResetTrafficCron = "RESET_TRAFFIC_CRON"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,19 +52,17 @@ const (
|
|||||||
var (
|
var (
|
||||||
// publicReadableConfigKeys — настройки, которые панель показывает оператору.
|
// publicReadableConfigKeys — настройки, которые панель показывает оператору.
|
||||||
publicReadableConfigKeys = map[string]struct{}{
|
publicReadableConfigKeys = map[string]struct{}{
|
||||||
Hysteria2TrafficTime: {},
|
|
||||||
ResetTrafficCron: {},
|
ResetTrafficCron: {},
|
||||||
Hysteria2ConfigRemark: {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicWritableConfigKeys — подмножество читаемых, которые оператор
|
// publicWritableConfigKeys — подмножество читаемых, которые оператор
|
||||||
// действительно редактирует в интерфейсе.
|
// действительно редактирует в интерфейсе.
|
||||||
//
|
//
|
||||||
// HYSTERIA2_CONFIG_REMARK сюда не входит: панель его только отображает.
|
// После очистки мёртвых ключей оба списка совпали: единственная настройка,
|
||||||
// Право записи, которым никто не пользуется, — это attack surface без
|
// которой владеет панель, — расписание сброса счётчиков трафика. Списки
|
||||||
// пользователя.
|
// намеренно не схлопнуты в один: читаемое и записываемое — разные права, и
|
||||||
|
// первый же ключ «показываем, но менять нельзя» вернёт разницу.
|
||||||
publicWritableConfigKeys = map[string]struct{}{
|
publicWritableConfigKeys = map[string]struct{}{
|
||||||
Hysteria2TrafficTime: {},
|
|
||||||
ResetTrafficCron: {},
|
ResetTrafficCron: {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -89,8 +99,6 @@ func InternalConfigKeys() []string {
|
|||||||
PeerSecretKey,
|
PeerSecretKey,
|
||||||
PeerSecretEncryptionKey,
|
PeerSecretEncryptionKey,
|
||||||
Hysteria2TrafficStatsSecret,
|
Hysteria2TrafficStatsSecret,
|
||||||
Hysteria2Enable,
|
|
||||||
Hysteria2Config,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,29 +34,42 @@ func TestSecretKeysAreListedAsInternal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestOperatorSettingsRemainAvailable(t *testing.T) {
|
func TestOperatorSettingsRemainAvailable(t *testing.T) {
|
||||||
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron, Hysteria2ConfigRemark} {
|
if !IsPublicReadableConfigKey(ResetTrafficCron) {
|
||||||
if !IsPublicReadableConfigKey(key) {
|
t.Errorf("пользовательская настройка %s недоступна на чтение", ResetTrafficCron)
|
||||||
t.Errorf("пользовательская настройка %s недоступна на чтение", key)
|
|
||||||
}
|
}
|
||||||
|
if !IsPublicWritableConfigKey(ResetTrafficCron) {
|
||||||
|
t.Errorf("пользовательская настройка %s недоступна на запись", ResetTrafficCron)
|
||||||
}
|
}
|
||||||
for _, key := range []string{Hysteria2TrafficTime, ResetTrafficCron} {
|
}
|
||||||
if !IsPublicWritableConfigKey(key) {
|
|
||||||
t.Errorf("пользовательская настройка %s недоступна на запись", key)
|
// Ключи, удалённые как мёртвое состояние, не должны вернуться в 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) {
|
func TestWritableIsSubsetOfReadable(t *testing.T) {
|
||||||
for _, key := range PublicWritableConfigKeys() {
|
for _, key := range PublicWritableConfigKeys() {
|
||||||
if !IsPublicReadableConfigKey(key) {
|
if !IsPublicReadableConfigKey(key) {
|
||||||
t.Errorf("ключ %s можно записать, но нельзя прочитать", key)
|
t.Errorf("ключ %s можно записать, но нельзя прочитать", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if IsPublicWritableConfigKey(Hysteria2ConfigRemark) {
|
|
||||||
t.Error("HYSTERIA2_CONFIG_REMARK не редактируется в панели и не должен быть записываемым")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownKeyIsClosedByDefault(t *testing.T) {
|
func TestUnknownKeyIsClosedByDefault(t *testing.T) {
|
||||||
|
|||||||
+20
-16
@@ -30,16 +30,9 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
verified, legacy := util.VerifyPassword(plainPassword, *admin.PasswordHash)
|
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
|
||||||
if !verified {
|
|
||||||
return "", false, errors.New(constant.WrongPassword)
|
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)
|
tokenVersion := int64(1)
|
||||||
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
||||||
tokenVersion = *admin.TokenVersion
|
tokenVersion = *admin.TokenVersion
|
||||||
@@ -49,21 +42,33 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
|||||||
if tokenErr != nil {
|
if tokenErr != nil {
|
||||||
return "", false, tokenErr
|
return "", false, tokenErr
|
||||||
}
|
}
|
||||||
requirePasswordChange := legacy
|
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
||||||
|
// с поддержкой самого формата: единственный источник этого требования —
|
||||||
|
// флаг force_password_change, который ставит установка.
|
||||||
|
requirePasswordChange := false
|
||||||
if admin.ForcePasswordChange != nil {
|
if admin.ForcePasswordChange != nil {
|
||||||
requirePasswordChange = *admin.ForcePasswordChange != 0
|
requirePasswordChange = *admin.ForcePasswordChange != 0
|
||||||
}
|
}
|
||||||
return token, requirePasswordChange, nil
|
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) {
|
func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) {
|
||||||
claims, ok := adminClaimsFromContext(c)
|
claims, ok := adminClaimsFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
myClaims, err := ParseToken(GetToken(c))
|
return vo.AdminInfoVo{}, errors.New(constant.UnauthorizedError)
|
||||||
if err != nil {
|
|
||||||
return vo.AdminInfoVo{}, err
|
|
||||||
}
|
|
||||||
claims = myClaims.Admin
|
|
||||||
}
|
}
|
||||||
admin, err := dao.GetAdminUser("id = ?", claims.Id)
|
admin, err := dao.GetAdminUser("id = ?", claims.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,8 +97,7 @@ func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string)
|
|||||||
if admin.PasswordHash == nil {
|
if admin.PasswordHash == nil {
|
||||||
return errors.New(constant.SysError)
|
return errors.New(constant.SysError)
|
||||||
}
|
}
|
||||||
verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash)
|
if !util.VerifyPassword(oldPassword, *admin.PasswordHash) {
|
||||||
if !verified {
|
|
||||||
return errors.New(constant.WrongPassword)
|
return errors.New(constant.WrongPassword)
|
||||||
}
|
}
|
||||||
hash, hashErr := util.HashPassword(newPassword)
|
hash, hashErr := util.HashPassword(newPassword)
|
||||||
|
|||||||
+62
-13
@@ -15,10 +15,53 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ConfigUpdate — одна пара «ключ/значение» из партии операторского API.
|
||||||
|
type ConfigUpdate struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
func UpdateConfig(key string, value string) error {
|
func UpdateConfig(key string, value string) error {
|
||||||
return dao.UpdateConfig([]string{key}, map[string]interface{}{"value": value})
|
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) {
|
func GetConfig(key string) (entity.Config, error) {
|
||||||
return dao.GetConfig("key = ?", key)
|
return dao.GetConfig("key = ?", key)
|
||||||
}
|
}
|
||||||
@@ -27,26 +70,29 @@ func ListConfig(keys []string) ([]entity.Config, error) {
|
|||||||
return dao.ListConfig("key in ?", keys)
|
return dao.ListConfig("key in ?", keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHysteria2Config читает ТОЛЬКО /etc/hysteria/config.yaml.
|
||||||
|
//
|
||||||
|
// Ветки «сначала посмотреть в SQLite, а если пусто — в файл» здесь больше нет.
|
||||||
|
// Ключ HYSTERIA2_CONFIG остался от предыдущего поколения, где панель сама
|
||||||
|
// владела серверным конфигом. В HY2XS владелец ровно один — install-оркестратор,
|
||||||
|
// маршруты записи конфига удалены, и писателя у этого ключа не осталось: clean
|
||||||
|
// install создавал его пустым, и пустым он оставался навсегда.
|
||||||
|
//
|
||||||
|
// Опасна была не бесполезность, а приоритет: БД читалась ПЕРВОЙ. Любое
|
||||||
|
// значение, попавшее в неё в обход продукта, молча становилось тем, что панель
|
||||||
|
// показывает оператору и из чего генерирует клиентские ссылки, пока Hysteria
|
||||||
|
// продолжала работать по файлу. То есть ключ был готовым вторым источником
|
||||||
|
// истины — ровно тем, что проект последовательно убирает.
|
||||||
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
|
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
|
||||||
var serverConfig bo.Hysteria2ServerConfig
|
var serverConfig bo.Hysteria2ServerConfig
|
||||||
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
|
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return serverConfig, err
|
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 {
|
if err = yaml.Unmarshal(content, &serverConfig); err != nil {
|
||||||
return serverConfig, err
|
return serverConfig, err
|
||||||
}
|
}
|
||||||
return serverConfig, nil
|
return serverConfig, nil
|
||||||
}
|
|
||||||
if err = yaml.Unmarshal([]byte(*config.Value), &serverConfig); err != nil {
|
|
||||||
return serverConfig, err
|
|
||||||
}
|
|
||||||
return serverConfig, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHysteria2ApiPort() (int64, error) {
|
func GetHysteria2ApiPort() (int64, error) {
|
||||||
@@ -54,15 +100,18 @@ func GetHysteria2ApiPort() (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
// Сообщение передаётся аргументом, а не форматной строкой: значение
|
||||||
|
// приходит из /etc/hysteria/config.yaml, и знак процента в нём превращал
|
||||||
|
// запись журнала в мусор вида `%!s(MISSING)`.
|
||||||
if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil {
|
if hysteria2Config.TrafficStats == nil || hysteria2Config.TrafficStats.Listen == nil {
|
||||||
errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil"
|
errMsg := "hysteria2 Traffic Stats API (HTTP) Listen is nil"
|
||||||
logrus.Errorf(errMsg)
|
logrus.Errorf("%s", errMsg)
|
||||||
return 0, errors.New(errMsg)
|
return 0, errors.New(errMsg)
|
||||||
}
|
}
|
||||||
apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
|
apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err)
|
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 0, errors.New(errMsg)
|
||||||
}
|
}
|
||||||
return apiPort, nil
|
return apiPort, nil
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("перепланирование на остановленном планировщике не отклонено")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"hy2xs-admin/dao"
|
|
||||||
"hy2xs-admin/model/constant"
|
|
||||||
"hy2xs-admin/util"
|
"hy2xs-admin/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,19 +10,19 @@ import (
|
|||||||
// Панель умеет только наблюдать состояние: функций Start/Stop/Restart и
|
// Панель умеет только наблюдать состояние: функций Start/Stop/Restart и
|
||||||
// смены версии здесь нет намеренно, а не «временно отключены».
|
// смены версии здесь нет намеренно, а не «временно отключены».
|
||||||
|
|
||||||
|
// InitHysteria2 проверяет единственное, что панели действительно нужно от
|
||||||
|
// Hysteria на старте: наличие бинарника, версию которого показывает дашборд и
|
||||||
|
// у которого спрашивает состояние Hysteria2IsRunning.
|
||||||
|
//
|
||||||
|
// Чтения HYSTERIA2_ENABLE здесь больше нет. Ключ был последним следом модели,
|
||||||
|
// в которой панель сама запускала и останавливала Hysteria: clean install
|
||||||
|
// создавал его со значением "0", записать "1" было некому, а вся ветка
|
||||||
|
// сводилась к строке в журнале. Настройка, которая ничем не управляет, но
|
||||||
|
// выглядит выключателем, хуже отсутствующей.
|
||||||
func InitHysteria2() error {
|
func InitHysteria2() error {
|
||||||
if !util.Exists(util.GetHysteria2BinPath()) {
|
if !util.Exists(util.GetHysteria2BinPath()) {
|
||||||
return errors.New("systemd-managed hysteria binary not found")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,15 +148,8 @@ func Hysteria2Url(accountId int64) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
remark := ""
|
remark := shareRemark(peer.Name, hostname)
|
||||||
if hysteria2ConfigRemark.Value != nil {
|
|
||||||
remark = *hysteria2ConfigRemark.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
obfs := hysteria2Config.ObfsShare()
|
obfs := hysteria2Config.ObfsShare()
|
||||||
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
|
sni := resolveShareSni(hysteria2Config.AcmeDomain(), hostname)
|
||||||
@@ -172,6 +165,27 @@ func Hysteria2Url(accountId int64) (string, error) {
|
|||||||
return buildHysteria2Url(secret, hostname, port, obfs, sni, remark), nil
|
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 перечисляет типы обфускации, которые официальная
|
// isShareableObfsType перечисляет типы обфускации, которые официальная
|
||||||
// URI-схема Hysteria умеет передавать клиенту.
|
// URI-схема Hysteria умеет передавать клиенту.
|
||||||
func isShareableObfsType(obfsType string) bool {
|
func isShareableObfsType(obfsType string) bool {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"hy2xs-admin/dao"
|
|
||||||
"hy2xs-admin/model/constant"
|
"hy2xs-admin/model/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,19 +20,17 @@ const RedactedPlaceholder = "<redacted>"
|
|||||||
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
|
// Это отдельный слой от GetHysteria2Config намеренно: типизированная модель
|
||||||
// отражает известные HY2XS поля и используется для отображения, а сырой YAML
|
// отражает известные HY2XS поля и используется для отображения, а сырой YAML
|
||||||
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
|
// нужен там, где нельзя потерять поля, о которых HY2XS пока не знает.
|
||||||
|
//
|
||||||
|
// Источник ровно один — файл. Ветка «сначала SQLite, потом файл» удалена
|
||||||
|
// вместе с ключом HYSTERIA2_CONFIG: см. GetHysteria2Config в service/config.go.
|
||||||
|
// Здесь у неё было дополнительное следствие — выгрузка «фактического
|
||||||
|
// серверного конфига» могла отдать оператору документ, по которому Hysteria
|
||||||
|
// никогда не работала.
|
||||||
func GetRawHysteria2Config() (string, error) {
|
func GetRawHysteria2Config() (string, error) {
|
||||||
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
|
content, err := os.ReadFile(constant.Hysteria2ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
return string(content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
"hy2xs-admin/model/vo"
|
"hy2xs-admin/model/vo"
|
||||||
"hy2xs-admin/util"
|
"hy2xs-admin/util"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -58,6 +60,22 @@ func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
|
|||||||
return result, len(result), nil
|
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 оператору.
|
// ExportHysteriaJournalLogs отдаёт журнал Hysteria оператору.
|
||||||
//
|
//
|
||||||
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
|
// Файл покидает сервер, поэтому проходит тот же санитайз, что и остальные
|
||||||
|
|||||||
+87
-36
@@ -2,59 +2,110 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/gin-gonic/gin"
|
"strings"
|
||||||
"github.com/golang-jwt/jwt"
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/bo"
|
"hy2xs-admin/model/bo"
|
||||||
"hy2xs-admin/model/constant"
|
"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
|
const TokenExpireDuration = time.Hour * 24
|
||||||
|
|
||||||
|
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
|
||||||
|
// не должен разбираться как наш даже при совпадении ключа.
|
||||||
|
const tokenIssuer = "hy2xs-admin"
|
||||||
|
|
||||||
type MyClaims struct {
|
type MyClaims struct {
|
||||||
Admin bo.AccountBo `json:"admin"`
|
Admin bo.AccountBo `json:"admin"`
|
||||||
jwt.StandardClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenToken(accountBo bo.AccountBo) (string, error) {
|
func jwtSecret() ([]byte, 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) {
|
|
||||||
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
|
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New(constant.SysError)
|
return nil, errors.New(constant.SysError)
|
||||||
}
|
}
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) {
|
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
|
||||||
|
// Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
|
||||||
|
// Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
|
||||||
|
// молча превращался в ключ нулевой длины.
|
||||||
|
return nil, errors.New(constant.SysError)
|
||||||
|
}
|
||||||
return []byte(*config.Value), nil
|
return []byte(*config.Value), nil
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New(constant.IllegalTokenError)
|
|
||||||
}
|
|
||||||
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
return nil, errors.New(constant.TokenExpiredError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetToken(c *gin.Context) string {
|
func GenToken(accountBo bo.AccountBo) (string, error) {
|
||||||
tokenStr := c.Request.Header.Get("Authorization")
|
secret, err := jwtSecret()
|
||||||
if tokenStr == "" {
|
if err != nil {
|
||||||
return ""
|
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. Разбор токена у продукта ровно один.
|
||||||
|
|||||||
@@ -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("разбор токена с пустым ключом не отклонён")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,10 +46,40 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) {
|
|||||||
return result, total, nil
|
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) {
|
func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
||||||
if peerDto.Name == nil || *peerDto.Name == "" {
|
if peerDto.Name == nil || *peerDto.Name == "" {
|
||||||
return vo.PeerVo{}, errors.New(constant.InvalidError)
|
return vo.PeerVo{}, errors.New(constant.InvalidError)
|
||||||
}
|
}
|
||||||
|
// Имя зарезервировано за установщиком даже когда сам пир уже удалён:
|
||||||
|
// иначе после удаления обычный пир мог бы занять это имя и оказаться под
|
||||||
|
// защитой, предназначенной не ему.
|
||||||
|
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||||||
|
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||||
|
}
|
||||||
if ExistPeerName(*peerDto.Name, 0) {
|
if ExistPeerName(*peerDto.Name, 0) {
|
||||||
return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name))
|
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 {
|
func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
||||||
|
if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
if peerDto.Name != nil && *peerDto.Name != "" {
|
if peerDto.Name != nil && *peerDto.Name != "" {
|
||||||
updates["name"] = *peerDto.Name
|
updates["name"] = *peerDto.Name
|
||||||
@@ -128,6 +162,47 @@ func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error {
|
|||||||
return dao.UpdatePeer([]int64{id}, updates)
|
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 DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
|
||||||
|
|
||||||
func GetPeerVo(id int64) (vo.PeerVo, error) {
|
func GetPeerVo(id int64) (vo.PeerVo, error) {
|
||||||
|
|||||||
@@ -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("пир установщика остался в базе после удаления")
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-47
@@ -1,59 +1,29 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/constant"
|
|
||||||
"hy2xs-admin/model/entity"
|
|
||||||
"hy2xs-admin/util"
|
"hy2xs-admin/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getOrCreateConfigKey(key string, size int) (string, error) {
|
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
|
||||||
cfg, err := dao.GetConfig("key = ?", key)
|
//
|
||||||
if err == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
|
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
|
||||||
return strings.TrimSpace(*cfg.Value), nil
|
// getPeerSecretEncryptionKey — построчные копии одноимённых функций из
|
||||||
}
|
// dao/sqlite.go. Две реализации, порождающие один и тот же материал шифрования,
|
||||||
raw, genErr := util.RandomString(size)
|
// в двух пакетах: bootstrap-пир получал ключ через dao, а всё остальное — через
|
||||||
if genErr != nil {
|
// service. Пока обе читали один ключ из таблицы `config`, поведение совпадало,
|
||||||
return "", genErr
|
// но любое расхождение означало бы, что секреты пиров шифруются одним ключом, а
|
||||||
}
|
// расшифровываются другим, и проявилось бы это на живых пирах.
|
||||||
value := raw
|
//
|
||||||
remark := key
|
// Копия в service несла ещё и порядок «сначала INSERT, при ошибке UPDATE»,
|
||||||
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 {
|
// `duplicated key not allowed` уровня error на здоровом старте.
|
||||||
return "", updErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
|
||||||
func GetPeerSecretKey() (string, error) {
|
func GetPeerSecretKey() (string, error) {
|
||||||
return getOrCreateConfigKey(constant.PeerSecretKey, 48)
|
return dao.GetOrCreatePeerSecretDigestKey()
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func PeerSecretDigest(rawSecret string) (string, error) {
|
func PeerSecretDigest(rawSecret string) (string, error) {
|
||||||
@@ -65,7 +35,7 @@ func PeerSecretDigest(rawSecret string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func EncryptPeerSecret(rawSecret string) (string, error) {
|
func EncryptPeerSecret(rawSecret string) (string, error) {
|
||||||
key, err := getPeerSecretEncryptionKey()
|
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -76,7 +46,7 @@ func DecryptPeerSecret(stored string) (string, error) {
|
|||||||
if !strings.HasPrefix(stored, "v1:") {
|
if !strings.HasPrefix(stored, "v1:") {
|
||||||
return stored, nil
|
return stored, nil
|
||||||
}
|
}
|
||||||
key, err := getPeerSecretEncryptionKey()
|
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-15
@@ -15,16 +15,22 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SHA224String(password string) string {
|
// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
|
||||||
hash := sha256.New224()
|
//
|
||||||
hash.Write([]byte(password))
|
// Это был формат хранения паролей предыдущего поколения: несолёный SHA-224,
|
||||||
val := hash.Sum(nil)
|
// то есть быстрый хеш общего назначения, по которому словарная атака идёт
|
||||||
str := ""
|
// на порядки быстрее, чем по bcrypt, а одинаковые пароли дают одинаковые
|
||||||
for _, v := range val {
|
// хеши. VerifyPassword принимал такие хеши как «legacy» и молча переписывал
|
||||||
str += fmt.Sprintf("%02x", v)
|
// их в bcrypt при первом успешном входе.
|
||||||
}
|
//
|
||||||
return str
|
// В HY2XS v1 такого хеша не может существовать. Единственные места, создающие
|
||||||
}
|
// учётную запись, — ensureSecureBootstrapAdmin и `reset-admin`, и оба пишут
|
||||||
|
// bcrypt; миграции 002/003, переносившие таблицу `account` предыдущего
|
||||||
|
// поколения, удалены; установка возможна только на чистый хост, а конфигурация
|
||||||
|
// 0.x отклоняется по HY2XS_CONFIG_SCHEMA_VERSION. То есть compatibility-ветка
|
||||||
|
// пережила ровно тот слой совместимости, ради которого существовала, и
|
||||||
|
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
|
||||||
|
// логина, доступном без аутентификации.
|
||||||
|
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
if len(strings.TrimSpace(password)) < 6 {
|
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$")
|
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$")
|
||||||
}
|
}
|
||||||
|
|
||||||
func VerifyPassword(password string, storedHash string) (ok bool, legacy bool) {
|
// VerifyPassword проверяет пароль ровно против одного формата хранения.
|
||||||
if IsBcryptHash(storedHash) {
|
//
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
|
// Хеш, который не является bcrypt, — это не «пароль в старом формате», а
|
||||||
return err == nil, false
|
// повреждённая или подменённая строка в базе, и правильный ответ на неё —
|
||||||
|
// отказ во входе.
|
||||||
|
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 {
|
func HmacSHA256Hex(payload string, secret string) string {
|
||||||
|
|||||||
@@ -1,7 +1,100 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSHA224String(t *testing.T) {
|
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
|
||||||
println(SHA224String("sysadmin"))
|
// проверял. Вместе с самой функцией он удалён: формат хранения паролей
|
||||||
|
// предыдущего поколения в 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-8
@@ -1,19 +1,55 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import "crypto/rand"
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
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) {
|
func RandomString(length int) (string, error) {
|
||||||
bytes := make([]byte, length)
|
if length <= 0 {
|
||||||
_, err := rand.Read(bytes)
|
return "", errors.New("random string length must be positive")
|
||||||
if err != nil {
|
}
|
||||||
|
|
||||||
|
out := make([]byte, 0, length)
|
||||||
|
buf := make([]byte, length)
|
||||||
|
|
||||||
|
for len(out) < length {
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
for _, b := range buf {
|
||||||
for i := range bytes {
|
if int(b) >= rejectionThreshold {
|
||||||
bytes[i] = charset[int(bytes[i])%len(charset)]
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, charset[int(b)%len(charset)])
|
||||||
|
if len(out) == length {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(bytes), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,16 +59,19 @@ HY2XS_TARGET_OS=debian
|
|||||||
HY2XS_TARGET_OS_VERSION=13
|
HY2XS_TARGET_OS_VERSION=13
|
||||||
HY2XS_TARGET_ARCH=amd64
|
HY2XS_TARGET_ARCH=amd64
|
||||||
|
|
||||||
GO_VERSION=1.21.13
|
GO_VERSION=1.26.7
|
||||||
GO_LINUX_AMD64_SHA256=<sha256>
|
GO_LINUX_AMD64_SHA256=<sha256>
|
||||||
BUN_VERSION=1.3.13
|
BUN_VERSION=1.3.13
|
||||||
BUN_LINUX_X64_SHA256=<sha256>
|
BUN_LINUX_X64_SHA256=<sha256>
|
||||||
BUN_LINUX_X64_BASELINE_SHA256=<sha256>
|
BUN_LINUX_X64_BASELINE_SHA256=<sha256>
|
||||||
NODE_VERSION=20.19.0
|
NODE_VERSION=24.20.0
|
||||||
NODE_LINUX_X64_SHA256=<sha256>
|
NODE_LINUX_X64_SHA256=<sha256>
|
||||||
PNPM_VERSION=9.15.9
|
PNPM_VERSION=9.15.9
|
||||||
|
|
||||||
HYSTERIA_CHANNEL=stable
|
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, поэтому
|
выбирает `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`
|
`profile.ts`, `package/config/hy2xs.env` и `packageManager` в двух `package.json`
|
||||||
|
|||||||
+148
-5
@@ -133,6 +133,16 @@ Hysteria обращается к машинному endpoint'у как
|
|||||||
сохраняет host, port и path — диагностика от него не страдает. Тот же проход
|
сохраняет host, port и path — диагностика от него не страдает. Тот же проход
|
||||||
применяется к `journal-*.log` внутри diagnostics-бандла оркестратора.
|
применяется к `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` | да | да |
|
| `RESET_TRAFFIC_CRON` | да | да |
|
||||||
| `HYSTERIA2_CONFIG_REMARK` | да | нет |
|
| `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор |
|
||||||
| `HYSTERIA2_ENABLE`, `HYSTERIA2_CONFIG`, `HYSTERIA2_TRAFFIC_STATS_SECRET` | нет | нет, владелец — оркестратор |
|
|
||||||
| `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет |
|
| `JWT_SECRET`, `PEER_SECRET_KEY`, `PEER_SECRET_ENCRYPTION_KEY` | нет | нет |
|
||||||
| любой другой | нет | нет |
|
| любой другой | нет | нет |
|
||||||
|
|
||||||
@@ -178,8 +186,53 @@ Denylist требует, чтобы автор каждого нового кл
|
|||||||
по умолчанию не зависит от внимательности.
|
по умолчанию не зависит от внимательности.
|
||||||
|
|
||||||
Маршрут `GET /api/config/getConfig` **удалён целиком**: потребителей у него не
|
Маршрут `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/` больше не существует.
|
`export/` больше не существует.
|
||||||
|
|
||||||
|
### Партия настроек применяется целиком или не применяется вовсе
|
||||||
|
|
||||||
|
`POST /api/config/updateConfigs` выполняется в три прохода:
|
||||||
|
|
||||||
|
1. проверка партии целиком — права на ключ, дубликаты ключей, значения;
|
||||||
|
2. одна транзакция базы;
|
||||||
|
3. применение к рантайму.
|
||||||
|
|
||||||
|
Раньше проходов не было: цикл проверял очередной элемент и тут же его записывал.
|
||||||
|
Партия «разрешённый ключ + запрещённый» применяла первый и возвращала ошибку на
|
||||||
|
втором — оператор получал отказ на запрос, который систему уже изменил.
|
||||||
|
|
||||||
|
Тест на этот случай существовал, но ставил запрещённый ключ **первым** и не
|
||||||
|
смотрел в базу — поймать частичное применение он был неспособен по построению.
|
||||||
|
Сейчас разрешённый ключ идёт первым, запрещённый вторым, а состояние базы
|
||||||
|
проверяется явно: `TestUpdateConfigsRefusesWholeBatchWhenLaterKeyIsForbidden`.
|
||||||
|
|
||||||
### Экспорт пиров: два режима, а не флаг
|
### Экспорт пиров: два режима, а не флаг
|
||||||
|
|
||||||
| Кнопка | Запрос | Что внутри |
|
| Кнопка | Запрос | Что внутри |
|
||||||
@@ -380,6 +450,72 @@ upstream выберет для нового секрета. Список мар
|
|||||||
- `HY2XS_ADMIN_DATA_DIR`
|
- `HY2XS_ADMIN_DATA_DIR`
|
||||||
- `HY2XS_ADMIN_LOG_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-поля
|
8. экспорт конфига сохраняет неизвестные upstream-поля
|
||||||
9. экспорт конфига не содержит секретов
|
9. экспорт конфига не содержит секретов
|
||||||
10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
|
10. сгенерированная `hysteria2://` ссылка содержит фактический тип обфускации, и совместимый клиент подключается по ней напрямую
|
||||||
|
11. планировщик существует в единственном экземпляре на процесс, а смена расписания не перезапускает HTTP-сервер
|
||||||
|
12. значение настройки проверяется до записи в базу тем же кодом, который его потом исполняет
|
||||||
|
13. партия настроек применяется целиком или не применяется вовсе
|
||||||
|
14. в таблице `config` нет ключей без потребителя
|
||||||
|
15. bootstrap-учётные данные приходят от оркестратора и никогда не генерируются и не логируются админкой
|
||||||
|
16. любой журнал, покидающий сервер, проходит санитайз
|
||||||
|
17. пароль администратора хранится ровно в одном формате — bcrypt
|
||||||
|
|||||||
@@ -293,7 +293,81 @@ wildcard-маршрутом фронтенда или дублирующая р
|
|||||||
- отказ наступает **до** обращения к базе (тест работает без SQLite — сам факт,
|
- отказ наступает **до** обращения к базе (тест работает без 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)
|
## A10. Импорт пиров (unit)
|
||||||
|
|
||||||
@@ -349,6 +423,43 @@ metadata пакета и версией, которую сообщает соб
|
|||||||
`hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр,
|
`hysteria-linux-amd64-avx`, форма `sha256:<hex>`, верхний регистр,
|
||||||
противоречивые записи, отсутствие нужной строки.
|
противоречивые записи, отсутствие нужной строки.
|
||||||
|
|
||||||
|
## 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
|
## B. Target install tests
|
||||||
|
|
||||||
### На чистом Debian 13 проверяем
|
### На чистом Debian 13 проверяем
|
||||||
@@ -384,9 +495,13 @@ metadata пакета и версией, которую сообщает соб
|
|||||||
17. `nft -c -f /etc/nftables.conf` проходит после apply
|
17. `nft -c -f /etc/nftables.conf` проходит после apply
|
||||||
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
|
18. пароль admin и `con_pass` не перезаписываются при рестарте `hy2xs-admin`
|
||||||
19. остановка/рестарт UI не останавливает `hysteria-server`
|
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`
|
21. `/etc/hysteria/config.yaml` имеет `0640 hysteria:hy2xs-admin`
|
||||||
22. `hy2xs-admin` может читать `/etc/hysteria/config.yaml`, но не может писать
|
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 конфига
|
## C1. Семантический smoke конфига
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,66 @@ Update the DNS A record before using this server.
|
|||||||
`HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` используются только как bootstrap-данные при первичной установке.
|
`HY2XS_ADMIN_INITIAL_PASSWORD` и `HY2XS_ADMIN_CON_PASS` используются только как bootstrap-данные при первичной установке.
|
||||||
Для ротации существующих credentials нужен отдельный flow на уровне account-management.
|
Для ротации существующих 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.
|
1. Не править сервер как будто на нём есть builder.
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ load_versions_contract
|
|||||||
. "$BUILD_DIR/lib/hysteria.sh"
|
. "$BUILD_DIR/lib/hysteria.sh"
|
||||||
# shellcheck source=tools/build/lib/acceptance.sh
|
# shellcheck source=tools/build/lib/acceptance.sh
|
||||||
. "$BUILD_DIR/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
|
# shellcheck source=tools/build/lib/package.sh
|
||||||
. "$BUILD_DIR/lib/package.sh"
|
. "$BUILD_DIR/lib/package.sh"
|
||||||
|
|
||||||
@@ -58,6 +60,13 @@ main() {
|
|||||||
log_step "Checking HY2XS admin contracts"
|
log_step "Checking HY2XS admin contracts"
|
||||||
run_admin_tests
|
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"
|
log_step "Writing metadata"
|
||||||
write_metadata "$PACKAGE_VERSION" "$BUILD_ID"
|
write_metadata "$PACKAGE_VERSION" "$BUILD_ID"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,39 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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 <<EOF
|
||||||
|
$(grep -rlF --exclude='*_test.go' -- "$needle" "$@" 2>/dev/null || true)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
printf '%s' "$hits"
|
||||||
|
}
|
||||||
|
|
||||||
run_fix20_acceptance_subset() {
|
run_fix20_acceptance_subset() {
|
||||||
local package_dir="$1"
|
local package_dir="$1"
|
||||||
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
|
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
|
||||||
@@ -371,6 +404,165 @@ run_clean_install_acceptance() {
|
|||||||
run_secret_channel_acceptance
|
run_secret_channel_acceptance
|
||||||
run_atomic_import_acceptance
|
run_atomic_import_acceptance
|
||||||
run_legacy_account_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 принадлежит оркестратору целиком.
|
# PHASE 1 принадлежит оркестратору целиком.
|
||||||
@@ -537,11 +729,23 @@ run_atomic_import_acceptance() {
|
|||||||
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|
||||||
|| fail "acceptance: WithPeerTx must open a real database transaction"
|
|| fail "acceptance: WithPeerTx must open a real database transaction"
|
||||||
# Применение обязано идти по tx, а не по глобальному соединению.
|
# Применение обязано идти по tx, а не по глобальному соединению.
|
||||||
|
#
|
||||||
|
# Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл
|
||||||
|
# от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все
|
||||||
|
# функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и
|
||||||
|
# UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта,
|
||||||
|
# которым глобальное соединение положено. Проверка гарантированно падала на
|
||||||
|
# корректном коде и не была замечена только потому, что сборка до неё не
|
||||||
|
# доходила: раньше неё падал versions contract на собственном скане '/hui'.
|
||||||
|
#
|
||||||
|
# Границей тела функции служит следующее объявление верхнего уровня.
|
||||||
"$BUN_BIN" -e '
|
"$BUN_BIN" -e '
|
||||||
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||||||
const start = source.indexOf("func applyPeerImportEntry");
|
const start = source.indexOf("func applyPeerImportEntry");
|
||||||
if (start < 0) throw new Error("applyPeerImportEntry is missing");
|
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("]) {
|
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);
|
if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ write_metadata() {
|
|||||||
printf 'bun_compile_target=%s\n' "${BUN_COMPILE_TARGET:-unknown}"
|
printf 'bun_compile_target=%s\n' "${BUN_COMPILE_TARGET:-unknown}"
|
||||||
printf 'node_version=%s\n' "$($NODE_BIN --version)"
|
printf 'node_version=%s\n' "$($NODE_BIN --version)"
|
||||||
printf 'pnpm_version=%s\n' "$($PNPM_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_source=official-upstream\n'
|
||||||
printf 'hysteria_target=linux-amd64\n'
|
printf 'hysteria_target=linux-amd64\n'
|
||||||
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
|
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ REQUIRED_CONTRACT_VARS=(
|
|||||||
NODE_LINUX_X64_SHA256
|
NODE_LINUX_X64_SHA256
|
||||||
PNPM_VERSION
|
PNPM_VERSION
|
||||||
HYSTERIA_CHANNEL
|
HYSTERIA_CHANNEL
|
||||||
|
GOVULNCHECK_VERSION
|
||||||
|
PNPM_AUDIT_LEVEL
|
||||||
)
|
)
|
||||||
|
|
||||||
validate_sha256_value() {
|
validate_sha256_value() {
|
||||||
@@ -99,6 +101,7 @@ load_versions_contract() {
|
|||||||
export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL
|
export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL
|
||||||
export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED
|
export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED
|
||||||
export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256
|
export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256
|
||||||
|
export GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без
|
# Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без
|
||||||
@@ -217,17 +220,31 @@ verify_api_namespace_contract() {
|
|||||||
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
|
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
|
||||||
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
|
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
|
||||||
#
|
#
|
||||||
# Список каталогов покрывает КАЖДЫЙ слой, который может собрать URL: раньше
|
# Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет.
|
||||||
# в нём не было apps/middleware, apps/cmd, apps/dao и apps/util, то есть
|
# Файлы, задача которых — доказать отсутствие legacy-пространства, из скана
|
||||||
# ровно тех мест, где живут HTTP-логгер, точка входа сервера и helpers.
|
# исключены: они обязаны называть его по имени, иначе проверять нечего.
|
||||||
|
#
|
||||||
|
# Регрессия приёмки. Раньше исключений не было, а в список каталогов входили
|
||||||
|
# tools/build и orchestrator/test. Контракт ловил сам себя: grep возвращал
|
||||||
|
# apps/router/router_test.go (регрессионный тест на отсутствие маршрута) и
|
||||||
|
# этот самый файл, где строка '/hui' стоит в тексте проверки. Сборка падала
|
||||||
|
# на шаге versions contract — то есть добавление теста, закрепляющего
|
||||||
|
# очистку, гарантированно ломало сборку.
|
||||||
|
#
|
||||||
|
# Гарантия при этом не ослабла, а переехала на слой, где она сильнее: скан
|
||||||
|
# исходников доказывает отсутствие СТРОКИ, а TestRouterHasNoLegacyNamespace
|
||||||
|
# проверяет ТАБЛИЦУ МАРШРУТОВ собранного роутера. Поэтому существование
|
||||||
|
# этого теста тоже входит в контракт и проверяется ниже.
|
||||||
local legacy_hits
|
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/model apps/router apps/controller apps/service apps/middleware \
|
||||||
apps/cmd apps/dao apps/util apps/frontend/src \
|
apps/cmd apps/dao apps/util apps/frontend/src \
|
||||||
orchestrator/src orchestrator/test package/templates \
|
orchestrator/src package/templates tools/test 2>/dev/null || true)"
|
||||||
tools/test tools/build 2>/dev/null || true)"
|
|
||||||
[ -z "$legacy_hits" ] \
|
[ -z "$legacy_hits" ] \
|
||||||
|| fail "versions contract: legacy /hui namespace came back in: $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() {
|
verify_go_toolchain_contract() {
|
||||||
|
|||||||
+39
-4
@@ -40,21 +40,56 @@ HY2XS_TARGET_ARCH=amd64
|
|||||||
|
|
||||||
# ------------------------------------------------------------ toolchain ---
|
# ------------------------------------------------------------ toolchain ---
|
||||||
|
|
||||||
GO_VERSION=1.21.13
|
# Go входит в production-артефакт, а не только в сборку: им компилируется
|
||||||
GO_LINUX_AMD64_SHA256=502fc16d5910562461e6a6631fb6377de2322aad7304bf2bcd23500ba9dab4a7
|
# 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, поэтому одной контрольной суммы
|
# 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_VERSION=1.3.13
|
||||||
BUN_LINUX_X64_SHA256=79c0771fa8b92c33aae41e15a0e0d307ea99d0e2f00317c71c6c53237a78e25a
|
BUN_LINUX_X64_SHA256=79c0771fa8b92c33aae41e15a0e0d307ea99d0e2f00317c71c6c53237a78e25a
|
||||||
BUN_LINUX_X64_BASELINE_SHA256=9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750
|
BUN_LINUX_X64_BASELINE_SHA256=9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750
|
||||||
|
|
||||||
NODE_VERSION=20.19.0
|
# Node живёт только на build-хосте (сборка frontend-ассетов) и в runtime-артефакт
|
||||||
NODE_LINUX_X64_SHA256=b4e336584d62abefad31baecff7af167268be9bb7dd11f1297112e6eed3ca0d5
|
# не попадает. Но 20.x достигла EOL, то есть перестала получать в том числе
|
||||||
|
# security-обновления, а собирает она код, который уезжает в production.
|
||||||
|
# 24.20.0 — текущая LTS-линия.
|
||||||
|
NODE_VERSION=24.20.0
|
||||||
|
NODE_LINUX_X64_SHA256=2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2
|
||||||
|
|
||||||
# pnpm ставится через corepack, отдельного архива с контрольной суммой нет.
|
# pnpm ставится через corepack, отдельного архива с контрольной суммой нет.
|
||||||
|
#
|
||||||
|
# Линия 9 оставлена вместе с apps/frontend/pnpm-lock.yaml: переход на 10 меняет
|
||||||
|
# формат lock-файла, то есть затрагивает воспроизводимость сборки frontend, и
|
||||||
|
# относится к обновлению его зависимостей, а не toolchain.
|
||||||
PNPM_VERSION=9.15.9
|
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 ---
|
# ------------------------------------------------ политика выбора Hysteria ---
|
||||||
|
|
||||||
# stable — резолвить последний стабильный upstream-релиз на build-машине;
|
# stable — резолвить последний стабильный upstream-релиз на build-машине;
|
||||||
|
|||||||
Reference in New Issue
Block a user